aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-02-07 21:41:19 -0600
committerCraig Jennings <c@cjennings.net>2026-02-07 21:41:19 -0600
commit24a681c0696fbdad9c32073ffd24cf7218296ed2 (patch)
treee5b43c8c62e027b7cabffa31b43238027ec284d0
parentbf6eef6183df6051b2423c7850c230406861f927 (diff)
downloadarchangel-24a681c0696fbdad9c32073ffd24cf7218296ed2.tar.gz
archangel-24a681c0696fbdad9c32073ffd24cf7218296ed2.zip
docs: sync templates, rename workflows and notes.org
Sync from templates. Rename NOTES.org to notes.org, session-wrap-up to wrap-it-up, retrospective-workflow to retrospective, session-start to startup. Update all references.
-rw-r--r--docs/PLAN-archangel-btrfs.org2
-rw-r--r--docs/notes.org (renamed from docs/NOTES.org)43
-rw-r--r--docs/previous-session-history.org4
-rw-r--r--docs/protocols.org115
-rw-r--r--docs/scripts/eml-view-and-extract-attachments-readme.org47
-rw-r--r--docs/scripts/eml-view-and-extract-attachments.py401
-rw-r--r--docs/scripts/tests/conftest.py77
-rw-r--r--docs/scripts/tests/fixtures/empty-body.eml16
-rw-r--r--docs/scripts/tests/fixtures/html-only.eml20
-rw-r--r--docs/scripts/tests/fixtures/multiple-received-headers.eml12
-rw-r--r--docs/scripts/tests/fixtures/no-received-headers.eml9
-rw-r--r--docs/scripts/tests/fixtures/plain-text.eml15
-rw-r--r--docs/scripts/tests/fixtures/with-attachment.eml27
-rw-r--r--docs/scripts/tests/test_extract_body.py96
-rw-r--r--docs/scripts/tests/test_extract_metadata.py65
-rw-r--r--docs/scripts/tests/test_generate_filenames.py157
-rw-r--r--docs/scripts/tests/test_integration_stdout.py68
-rw-r--r--docs/scripts/tests/test_parse_received_headers.py105
-rw-r--r--docs/scripts/tests/test_process_eml.py129
-rw-r--r--docs/scripts/tests/test_save_attachments.py97
-rw-r--r--docs/workflows/create-v2mom.org10
-rw-r--r--docs/workflows/create-workflow.org12
-rw-r--r--docs/workflows/email-assembly.org183
-rw-r--r--docs/workflows/extract-email.org116
-rw-r--r--docs/workflows/find-email.org122
-rw-r--r--docs/workflows/journal-entry.org15
-rw-r--r--docs/workflows/refactor.org4
-rw-r--r--docs/workflows/retrospective.org (renamed from docs/workflows/retrospective-workflow.org)4
-rw-r--r--docs/workflows/send-email.org198
-rw-r--r--docs/workflows/session-start.org540
-rw-r--r--docs/workflows/set-alarm.org165
-rw-r--r--docs/workflows/startup.org103
-rw-r--r--docs/workflows/status-check.org178
-rw-r--r--docs/workflows/sync-email.org108
-rw-r--r--docs/workflows/whats-next.org4
-rw-r--r--docs/workflows/wrap-it-up.org (renamed from docs/workflows/session-wrap-up.org)34
36 files changed, 2650 insertions, 651 deletions
diff --git a/docs/PLAN-archangel-btrfs.org b/docs/PLAN-archangel-btrfs.org
index 9d91db7..20f1984 100644
--- a/docs/PLAN-archangel-btrfs.org
+++ b/docs/PLAN-archangel-btrfs.org
@@ -202,7 +202,7 @@ Goal: Update all docs for dual-filesystem support.
** 6.3 New docs
- [ ] Create BTRFS.org with btrfs-specific details
-- [ ] Update NOTES.org project context
+- [ ] Update notes.org project context
* Schedule (Suggested Order)
diff --git a/docs/NOTES.org b/docs/notes.org
index 51a31f3..f52ab20 100644
--- a/docs/NOTES.org
+++ b/docs/notes.org
@@ -175,7 +175,7 @@ Workflow:
2. Assess completeness
3. Name the workflow
4. Document it
-5. Update NOTES.org
+5. Update notes.org
6. Validate by execution
Created: [Date when workflow was created]
@@ -201,8 +201,8 @@ Applicable to: Any project (health, finance, software, personal infrastructure,
Created: 2025-11-05
-** session-start
-File: [[file:workflows/session-start.org][docs/workflows/session-start.org]]
+** startup
+File: [[file:workflows/startup.org][docs/workflows/startup.org]]
Workflow for beginning a Claude Code session with proper context and priorities.
@@ -210,9 +210,9 @@ Triggered by: **Automatically at the start of EVERY session**
Workflow:
1. Add session start timestamp (check for interrupted sessions)
-2. Sync with templates (exclude NOTES.org and previous-session-history.org)
+2. Sync with templates (exclude notes.org and previous-session-history.org)
3. Scan workflows directory for available workflows
-4. Read key NOTES.org sections (NOT entire file)
+4. Read key notes.org sections (NOT entire file)
5. Process inbox (mandatory)
6. Ask about priorities (urgent work vs what's-next workflow)
@@ -220,15 +220,15 @@ Ensures: Full context, current templates, processed inbox, clear session directi
Created: 2025-11-14
-** session-wrap-up
-File: [[file:workflows/session-wrap-up.org][docs/workflows/session-wrap-up.org]]
+** wrap-it-up
+File: [[file:workflows/wrap-it-up.org][docs/workflows/wrap-it-up.org]]
Workflow for ending a Claude Code session cleanly with proper documentation and version control.
Triggered by: "wrap it up," "that's a wrap," "let's call it a wrap," or similar phrases
Workflow:
-1. Write session notes to NOTES.org Session History section
+1. Write session notes to notes.org Session History section
2. Archive sessions older than 5 sessions to previous-session-history.org
3. Git commit and push all changes (NO Claude attribution)
4. Provide brief valediction with accomplishments and next steps
@@ -327,6 +327,33 @@ Each entry should use this format:
** Session Entries
+*** 2026-02-07 Sat @ 21:36 -0600
+
+*Status:* COMPLETE
+
+*What We Completed:*
+- Synced templates from claude-templates (protocols.org, workflows, scripts updated)
+- Executed announcements:
+ 1. Deleted old renamed workflows (session-wrap-up.org, retrospective-workflow.org, session-start.org) - replaced by wrap-it-up.org, retrospective.org, startup.org
+ 2. Confirmed no docs/templates/ cache to remove
+ 3. Renamed NOTES.org to notes.org, updated all internal references
+- Updated workflow catalog in notes.org to reflect renamed workflows
+
+*Files Modified:*
+- [[file:notes.org][docs/notes.org]] - Renamed from NOTES.org, updated references and workflow catalog
+- [[file:protocols.org][docs/protocols.org]] - Synced from template
+- [[file:previous-session-history.org][docs/previous-session-history.org]] - Updated NOTES.org references
+- [[file:PLAN-archangel-btrfs.org][docs/PLAN-archangel-btrfs.org]] - Updated NOTES.org reference
+
+*Files Deleted:*
+- docs/workflows/session-wrap-up.org (replaced by wrap-it-up.org)
+- docs/workflows/retrospective-workflow.org (replaced by retrospective.org)
+- docs/workflows/session-start.org (replaced by startup.org)
+
+*Files Added (from template):*
+- New workflows: wrap-it-up.org, retrospective.org, startup.org, set-alarm.org, status-check.org, send-email.org, find-email.org, extract-email.org, sync-email.org, email-assembly.org
+- docs/scripts/ updates
+
*** 2026-01-25 Sun @ 00:15-08:34 -0600
*Status:* COMPLETE
diff --git a/docs/previous-session-history.org b/docs/previous-session-history.org
index a209e01..8a87058 100644
--- a/docs/previous-session-history.org
+++ b/docs/previous-session-history.org
@@ -4,7 +4,7 @@
* About This File
-This file contains archived session history entries older than 2 weeks from the current date. Sessions are automatically moved here during the wrap-up workflow to keep NOTES.org at a manageable size.
+This file contains archived session history entries older than 2 weeks from the current date. Sessions are automatically moved here during the wrap-up workflow to keep notes.org at a manageable size.
Sessions are listed in reverse chronological order (most recent first).
@@ -21,7 +21,7 @@ Sessions are listed in reverse chronological order (most recent first).
- Added docs/ to git (decided to track publicly)
- Built fresh ISO (archzfs-claude-2026.01.17-x86_64.iso, 4.9G)
- Tested ISO in QEMU VM
-- Documented project goals and design decisions in NOTES.org
+- Documented project goals and design decisions in notes.org
*Key Decisions Made:*
- Use linux-lts + zfs-dkms from archzfs.com (DKMS ensures kernel compatibility)
diff --git a/docs/protocols.org b/docs/protocols.org
index d0032a6..bb52ae4 100644
--- a/docs/protocols.org
+++ b/docs/protocols.org
@@ -7,22 +7,40 @@
This file contains instructions and protocols for how Claude should behave when working with Craig. These protocols are consistent across all projects.
**When to read this:**
-- At the start of EVERY session (before NOTES.org)
+- At the start of EVERY session (this is the single entry point)
- Before making any significant decisions
- When unclear about user preferences or conventions
**What's in this file:**
+- Directory architecture (docs/ file/directory map)
- Session management protocols (context files, compacting)
- Terminology and trigger phrases
- User information and preferences
- Git commit requirements
- File format and naming conventions
+- Startup instructions (runs docs/workflows/startup.org)
**What's NOT in this file:**
-- Project-specific context (see NOTES.org)
-- Session history (see NOTES.org)
-- Active reminders (see NOTES.org)
-- Pending decisions (see NOTES.org)
+- Project-specific context (see notes.org)
+- Session history (see notes.org)
+- Active reminders (see notes.org)
+- Pending decisions (see notes.org)
+
+* Directory Architecture
+
+The =docs/= directory has a specific structure. Every file and directory has a defined purpose:
+
+| Item | Purpose |
+|------+---------|
+| =protocols.org= | Single entry point — behavioral instructions + directory map |
+| =notes.org= | Project data: context, reminders, decisions, session history |
+| =session-context.org= | Live crash recovery (exists only during active sessions) |
+| =previous-session-history.org= | Archived session history |
+| =workflows/= | Template workflows (synced from claude-templates, never edit in project) |
+| =project-workflows/= | Project-specific workflows (never touched by sync) |
+| =scripts/= | Template scripts |
+| =announcements/= | One-off cross-project instructions from Craig |
+| =someday-maybe.org= | Project ideas backlog |
* IMPORTANT - MUST DO
@@ -93,7 +111,7 @@ Include:
*** Why This Is Non-Negotiable
If the machine freezes, the network drops, or Claude crashes:
-- Session history in NOTES.org? NOT WRITTEN YET (only at wrap-up)
+- Session history in notes.org? NOT WRITTEN YET (only at wrap-up)
- Your memory of the conversation? GONE
- The ONLY way to recover context? THIS FILE
@@ -109,7 +127,15 @@ If you know you're about to compact, update the session context file FIRST, with
Review the session context file to make sure you aren't forgetting key aspects of our discussion or plan, then continue working with the user.
** When Session Ends (Wrap-Up Workflow)
-Write your session summary to NOTES.org, leveraging the session context file. Delete session-context.org ONLY AFTER you've written the session history entry. The file's existence indicates an interrupted session, so it must be deleted at the end of each successful wrap-up.
+Write your session summary to notes.org, leveraging the session context file. Delete session-context.org ONLY AFTER you've written the session history entry. The file's existence indicates an interrupted session, so it must be deleted at the end of each successful wrap-up.
+
+** NEVER =cd= Into Directories You Will Delete
+
+If you =cd= into a directory and then delete that directory, the shell's working directory becomes invalid. All subsequent commands will fail silently (exit code 1) with no useful error message. The only fix is to restart the session.
+
+***Rule:*** Always use absolute paths for file operations in temporary directories. Never =cd= into extraction directories, build directories, or any directory that will be cleaned up.
+
+This caused a session break on 2026-02-06 when an extraction directory was =cd='d into and then deleted during cleanup.
* Important Terminology
@@ -131,12 +157,12 @@ This does **NOT** mean "let's DO X right now."
*Example:*
- "I want to create a refactor workflow" -> Create docs/workflows/refactor.org using create-workflow process
-When Craig uses this phrasing, trigger the create-workflow process from docs/workflows/create-workflow.org.
+When Craig uses this phrasing, trigger the create-workflow process from docs/workflows/create-workflow.org. New workflows go to =docs/project-workflows/= by default. Only put a workflow in =docs/workflows/= (and =~/projects/claude-templates/docs/workflows/=) if Craig explicitly says it's for all projects.
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
Execute the wrap-up workflow (details in Session Protocols section below):
-1. Write session notes to NOTES.org
+1. Write session notes to notes.org
2. Git commit and push all changes
3. Valediction summary
@@ -158,7 +184,7 @@ Craig's global task list is available at: =/home/cjennings/sync/org/roam/inbox.o
Use this to:
- See all the tasks that he's working on outside of projects like this one
-**Note:** Some projects may have a project-specific task file (e.g., =todo.org= at project root). Check NOTES.org for project-specific task locations.
+**Note:** Some projects may have a project-specific task file (e.g., =todo.org= at project root). Check notes.org for project-specific task locations.
** Working Style
@@ -189,19 +215,33 @@ Craig runs a pure Wayland setup (Hyprland) and avoids XWayland/Xorg apps.
- Craig maintains a remote server at the cjennings.net domain
- This project is in a git repository which is associated with a remote repository on cjennings.net
-** Shell Functions - Alarms
+** Setting Alarms / Reminders
-Craig has a shell function for setting reminders.
+Use Craig's =notify= script with the =at= daemon for persistent reminders.
**IMPORTANT:** Always check the current date and time (=date=) before setting alarms to ensure accurate calculations.
-*** alarm (PERSISTENT - use for reminders)
-Sets an alarm at a specific time using the =at= daemon. Persists after session ends.
+*** Setting an alarm
+#+begin_src bash
+echo 'notify alarm "Title" "Message"' | at 10:55am
+#+end_src
+
+*** Examples
#+begin_example
-alarm 10:10am "Take BP reading"
-alarm 2:00pm "Leave for PT"
+echo 'notify alarm "Standup" "Daily standup in 5 minutes"' | at 10:55am
+echo 'notify alarm "BP Reading" "Time to take BP"' | at 2:00pm
+echo 'notify alert "Meeting" "Ryan call starting"' | at 11:25am
#+end_example
+*** Notify types available
+- =alarm= - Alarm clock icon, alarm sound
+- =alert= - Yellow exclamation, attention tone
+- =info= - Blue info icon, confident tone
+- =success= - Green checkmark, pleasant chime
+- =fail= - Red X, warning tone
+
+Full usage: =notify --help= or see =~/.local/bin/notify=
+
*** Managing alarms
- =atq= - list all scheduled alarms
- =atrm [number]= - remove an alarm by its queue number
@@ -237,12 +277,12 @@ When creating commits:
** IMPORTANT: Reminders Protocol
When starting a new session:
-- Check "Active Reminders" section in NOTES.org
+- Check "Active Reminders" section in notes.org
- Remind Craig of outstanding tasks he's asked to be reminded about
- This ensures important follow-up actions aren't forgotten between sessions
When Craig says "remind me" about something:
-1. Add it to Active Reminders section in NOTES.org
+1. Add it to Active Reminders section in notes.org
2. If it's something he needs to DO, also add to the todo.org file in the project root as an org-mode task (e.g., =* TODO [description]=). If this project does not have a todo.org at the project root, alert Craig and offer to create it.
3. If not already provided, ask for the priority and a date for scheduled or deadline.
@@ -250,17 +290,20 @@ When Craig says "remind me" about something:
When Craig says this phrase:
-1. **Check for exact match** in docs/workflows/ directory
- - If exact match found: Read =docs/workflows/[workflow-name].org= and guide through process
+1. **Check =docs/workflows/= for match**
+ - If exact match found: Read and guide through process
- Example: "refactor workflow" -> read docs/workflows/refactor.org
-2. **If no exact match but similar word exists:** Ask for clarification
+2. **Check =docs/project-workflows/= for match**
+ - If exact match found: Read and guide through process
+
+3. **Fuzzy match across both directories:** Ask for clarification
- Example: User says "empty inbox" but we have "inbox-zero.org"
- Ask: "Did you mean the 'inbox zero' workflow, or create new 'empty inbox'?"
-3. **If no match at all:** Offer to create it
+4. **No match at all:** Offer to create it
- Say: "I don't see '[workflow-name]' yet. Create it using create-workflow process?"
- - If yes: Run create-workflow to define it, then use immediately (validates new workflow)
+ - If yes: Run create-workflow — new workflows go to =docs/project-workflows/= by default
** Long-Running Process Status Updates
@@ -313,7 +356,7 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
When Craig says any of these phrases (or variations), execute wrap-up workflow:
-1. **Write session notes** to NOTES.org (Session History section)
+1. **Write session notes** to notes.org (Session History section)
- Key decisions made
- Work completed
- Context needed for next session
@@ -342,17 +385,17 @@ This ensures clean handoff between sessions and nothing gets lost.
** The docs/ Directory
-Claude needs to add information to NOTES.org. For large amounts of information:
+Claude needs to add information to notes.org. For large amounts of information:
- Create separate document in docs/ directory
-- Link it in NOTES.org with explanation of document's purpose
+- Link it in notes.org with explanation of document's purpose
- **Project-specific decision:** Should docs/ be committed to git or added to .gitignore?
- Ask Craig on first session if not specified
- Some projects keep docs/ private, others commit it
- Unless specified otherwise, all Claude-generated documents go in docs/ folder
**When to break out documents:**
-- If NOTES.org gets very large (> 1500 lines)
+- If notes.org gets very large (> 1500 lines)
- If information isn't all relevant anymore
- Example: Keep only last 3-4 months of session history here, move rest to separate file
@@ -448,20 +491,4 @@ mv documents/2025-10-15-invoice.pdf documents/2025-10-15-vendor-invoice.pdf
* Session Start - AUTOMATIC
-**IMPORTANT: At the start of EVERY session, Claude MUST:**
-
-1. **Read this file (protocols.org)** - You're doing this now
-2. **Read NOTES.org** - For project-specific context, reminders, and history
-3. **Execute the session-start workflow** - Defined in [[file:workflows/session-start.org][docs/workflows/session-start.org]]
-
-**Do NOT ask** if Craig wants to run the session-start workflow. Just do it automatically and report the results.
-
-The session-start workflow includes:
-- Checking for interrupted previous sessions
-- Syncing with templates
-- Scanning available workflows
-- Processing inbox
-- Surfacing active reminders
-- Asking about priorities
-
-See [[file:workflows/session-start.org][session-start.org]] for full details.
+At the start of EVERY session, run [[file:workflows/startup.org][docs/workflows/startup.org]]. Do NOT ask — just do it automatically.
diff --git a/docs/scripts/eml-view-and-extract-attachments-readme.org b/docs/scripts/eml-view-and-extract-attachments-readme.org
new file mode 100644
index 0000000..c132df8
--- /dev/null
+++ b/docs/scripts/eml-view-and-extract-attachments-readme.org
@@ -0,0 +1,47 @@
+#+TITLE: eml-view-and-extract-attachments.py
+
+Extract email content and attachments from EML files with auto-renaming.
+
+* Usage
+
+#+begin_src bash
+# View mode — print metadata and body to stdout, extract attachments alongside EML
+python3 docs/scripts/eml-view-and-extract-attachments.py inbox/message.eml
+
+# Pipeline mode — extract, auto-rename, refile to output dir, clean up
+python3 docs/scripts/eml-view-and-extract-attachments.py inbox/message.eml --output-dir assets/
+#+end_src
+
+* Naming Convention
+
+Files are auto-renamed as =YYYY-MM-DD-HHMM-Sender-TYPE-Description.ext=:
+
+- =2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.eml=
+- =2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.txt=
+- =2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf=
+
+Date and sender are parsed from email headers. Falls back to "unknown" for missing values.
+
+* Dependencies
+
+- Python 3 (stdlib only for core functionality)
+- =html2text= (optional — used for HTML-only emails, falls back to tag stripping)
+
+* Pipeline Mode Behavior
+
+1. Creates a temp directory alongside the source EML
+2. Copies and renames the EML, writes a =.txt= of the body, extracts attachments
+3. Checks for filename collisions in the output directory
+4. Moves all files to the output directory
+5. Cleans up the temp directory
+6. Prints a summary of created files
+
+Source EML is never modified or moved.
+
+* Tests
+
+#+begin_src bash
+python3 -m pytest docs/scripts/tests/ -v
+#+end_src
+
+48 tests: unit tests for parsing, filename generation, and attachment saving; integration tests for both pipeline and stdout modes. Requires =pytest=.
diff --git a/docs/scripts/eml-view-and-extract-attachments.py b/docs/scripts/eml-view-and-extract-attachments.py
index f498b83..3201c99 100644
--- a/docs/scripts/eml-view-and-extract-attachments.py
+++ b/docs/scripts/eml-view-and-extract-attachments.py
@@ -1,34 +1,343 @@
#!/usr/bin/env python3
+"""Extract email content and attachments from EML files.
+
+Without --output-dir: parse and print to stdout (backwards compatible).
+With --output-dir: full pipeline — extract, auto-rename, refile, clean up.
+"""
+
+import argparse
import email
-import sys
+import email.utils
import os
+import re
+import shutil
+import sys
+import tempfile
-def extract_attachments(eml_file):
- with open(eml_file, 'rb') as f:
- msg = email.message_from_binary_file(f)
- # Extract plain text body
- body_text = ""
+# ---------------------------------------------------------------------------
+# Parsing functions (no I/O beyond reading the input file)
+# ---------------------------------------------------------------------------
+
+def parse_received_headers(msg):
+ """Parse Received headers to extract sent/received times and servers."""
+ received_headers = msg.get_all('Received', [])
+
+ sent_server = None
+ sent_time = None
+ received_server = None
+ received_time = None
+
+ for header in received_headers:
+ header = ' '.join(header.split())
+
+ time_match = re.search(r';\s*(.+)$', header)
+ timestamp = time_match.group(1).strip() if time_match else None
+
+ from_match = re.search(r'from\s+([\w.-]+)', header)
+ by_match = re.search(r'by\s+([\w.-]+)', header)
+
+ if from_match and by_match and received_server is None:
+ received_time = timestamp
+ received_server = by_match.group(1)
+ sent_server = from_match.group(1)
+ sent_time = timestamp
+
+ if received_server is None and received_headers:
+ header = ' '.join(received_headers[0].split())
+ time_match = re.search(r';\s*(.+)$', header)
+ received_time = time_match.group(1).strip() if time_match else None
+ by_match = re.search(r'by\s+([\w.-]+)', header)
+ received_server = by_match.group(1) if by_match else "unknown"
+
+ return {
+ 'sent_time': sent_time,
+ 'sent_server': sent_server,
+ 'received_time': received_time,
+ 'received_server': received_server
+ }
+
+
+def extract_body(msg):
+ """Walk MIME parts, prefer text/plain, fall back to html2text on text/html.
+
+ Returns body text string.
+ """
+ plain_text = None
+ html_text = None
+
+ for part in msg.walk():
+ content_type = part.get_content_type()
+ if content_type == "text/plain" and plain_text is None:
+ payload = part.get_payload(decode=True)
+ if payload is not None:
+ plain_text = payload.decode('utf-8', errors='ignore')
+ elif content_type == "text/html" and html_text is None:
+ payload = part.get_payload(decode=True)
+ if payload is not None:
+ html_text = payload.decode('utf-8', errors='ignore')
+
+ if plain_text is not None:
+ return plain_text
+
+ if html_text is not None:
+ try:
+ import html2text
+ h = html2text.HTML2Text()
+ h.body_width = 0
+ return h.handle(html_text)
+ except ImportError:
+ # Strip HTML tags as fallback if html2text not installed
+ return re.sub(r'<[^>]+>', '', html_text)
+
+ return ""
+
+
+def extract_metadata(msg):
+ """Extract email metadata from headers.
+
+ Returns dict with from, to, subject, date, and timing info.
+ """
+ return {
+ 'from': msg.get('From'),
+ 'to': msg.get('To'),
+ 'subject': msg.get('Subject'),
+ 'date': msg.get('Date'),
+ 'timing': parse_received_headers(msg),
+ }
+
+
+def generate_basename(metadata):
+ """Generate date-sender prefix from metadata.
+
+ Returns e.g. "2026-02-05-1136-Jonathan".
+ Falls back to "unknown" for missing/malformed Date or From.
+ """
+ # Parse date
+ date_str = metadata.get('date')
+ date_prefix = "unknown"
+ if date_str:
+ try:
+ parsed = email.utils.parsedate_to_datetime(date_str)
+ date_prefix = parsed.strftime('%Y-%m-%d-%H%M')
+ except (ValueError, TypeError):
+ pass
+
+ # Parse sender first name
+ from_str = metadata.get('from')
+ sender = "unknown"
+ if from_str:
+ # Extract display name or email local part
+ display_name, addr = email.utils.parseaddr(from_str)
+ if display_name:
+ sender = display_name.split()[0]
+ elif addr:
+ sender = addr.split('@')[0]
+
+ return f"{date_prefix}-{sender}"
+
+
+def _clean_for_filename(text, max_length=80):
+ """Clean text for use in a filename.
+
+ Replace spaces with hyphens, strip chars unsafe for filenames,
+ collapse multiple hyphens.
+ """
+ text = text.strip()
+ text = text.replace(' ', '-')
+ # Keep alphanumeric, hyphens, dots, underscores
+ text = re.sub(r'[^\w\-.]', '', text)
+ # Collapse multiple hyphens
+ text = re.sub(r'-{2,}', '-', text)
+ # Strip leading/trailing hyphens
+ text = text.strip('-')
+ if len(text) > max_length:
+ text = text[:max_length].rstrip('-')
+ return text
+
+
+def generate_email_filename(basename, subject):
+ """Generate email filename from basename and subject.
+
+ Returns e.g. "2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street"
+ (without extension — caller adds .eml or .txt).
+ """
+ if subject:
+ clean_subject = _clean_for_filename(subject)
+ else:
+ clean_subject = "no-subject"
+ return f"{basename}-EMAIL-{clean_subject}"
+
+
+def generate_attachment_filename(basename, original_filename):
+ """Generate attachment filename from basename and original filename.
+
+ Returns e.g. "2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf".
+ Preserves original extension.
+ """
+ if not original_filename:
+ return f"{basename}-ATTACH-unnamed"
+
+ name, ext = os.path.splitext(original_filename)
+ clean_name = _clean_for_filename(name)
+ return f"{basename}-ATTACH-{clean_name}{ext}"
+
+
+# ---------------------------------------------------------------------------
+# I/O functions (file operations)
+# ---------------------------------------------------------------------------
+
+def save_attachments(msg, output_dir, basename):
+ """Write attachment files to output_dir with auto-renamed filenames.
+
+ Returns list of dicts: {original_name, renamed_name, path}.
+ """
+ results = []
for part in msg.walk():
- if part.get_content_type() == "text/plain":
- body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
- break
- elif part.get_content_type() == "text/html":
- # Fallback to HTML if no plain text
- if not body_text:
- body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
-
- # Print email metadata and body
- print(f"From: {msg.get('From')}")
- print(f"To: {msg.get('To')}")
- print(f"Subject: {msg.get('Subject')}")
- print(f"Date: {msg.get('Date')}")
+ if part.get_content_maintype() == 'multipart':
+ continue
+ if part.get('Content-Disposition') is None:
+ continue
+
+ filename = part.get_filename()
+ if filename:
+ renamed = generate_attachment_filename(basename, filename)
+ filepath = os.path.join(output_dir, renamed)
+ with open(filepath, 'wb') as f:
+ f.write(part.get_payload(decode=True))
+ results.append({
+ 'original_name': filename,
+ 'renamed_name': renamed,
+ 'path': filepath,
+ })
+
+ return results
+
+
+def save_text(text, filepath):
+ """Write body text to a .txt file."""
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write(text)
+
+
+# ---------------------------------------------------------------------------
+# Pipeline function
+# ---------------------------------------------------------------------------
+
+def process_eml(eml_path, output_dir):
+ """Full extraction pipeline.
+
+ 1. Create temp extraction dir
+ 2. Copy EML into temp dir
+ 3. Parse email (metadata, body, attachments)
+ 4. Generate filenames from headers
+ 5. Save renamed .eml, .txt, and attachments to temp dir
+ 6. Check for collisions in output_dir
+ 7. Move all files to output_dir
+ 8. Clean up temp dir
+ 9. Return results dict
+ """
+ eml_path = os.path.abspath(eml_path)
+ output_dir = os.path.abspath(output_dir)
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Create temp dir as sibling of the EML file
+ eml_dir = os.path.dirname(eml_path)
+ temp_dir = tempfile.mkdtemp(prefix='extract-', dir=eml_dir)
+
+ try:
+ # Copy EML to temp dir
+ temp_eml = os.path.join(temp_dir, os.path.basename(eml_path))
+ shutil.copy2(eml_path, temp_eml)
+
+ # Parse
+ with open(eml_path, 'rb') as f:
+ msg = email.message_from_binary_file(f)
+
+ metadata = extract_metadata(msg)
+ body = extract_body(msg)
+ basename = generate_basename(metadata)
+ email_stem = generate_email_filename(basename, metadata['subject'])
+
+ # Save renamed EML
+ renamed_eml = f"{email_stem}.eml"
+ renamed_eml_path = os.path.join(temp_dir, renamed_eml)
+ os.rename(temp_eml, renamed_eml_path)
+
+ # Save .txt
+ renamed_txt = f"{email_stem}.txt"
+ renamed_txt_path = os.path.join(temp_dir, renamed_txt)
+ save_text(body, renamed_txt_path)
+
+ # Save attachments
+ attachment_results = save_attachments(msg, temp_dir, basename)
+
+ # Build file list
+ files = [
+ {'type': 'eml', 'name': renamed_eml, 'path': None},
+ {'type': 'txt', 'name': renamed_txt, 'path': None},
+ ]
+ for att in attachment_results:
+ files.append({
+ 'type': 'attach',
+ 'name': att['renamed_name'],
+ 'path': None,
+ })
+
+ # Check for collisions in output_dir
+ for file_info in files:
+ dest = os.path.join(output_dir, file_info['name'])
+ if os.path.exists(dest):
+ raise FileExistsError(
+ f"Collision: '{file_info['name']}' already exists in {output_dir}"
+ )
+
+ # Move all files to output_dir
+ for file_info in files:
+ src = os.path.join(temp_dir, file_info['name'])
+ dest = os.path.join(output_dir, file_info['name'])
+ shutil.move(src, dest)
+ file_info['path'] = dest
+
+ return {
+ 'metadata': metadata,
+ 'body': body,
+ 'files': files,
+ }
+
+ finally:
+ # Clean up temp dir
+ if os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+
+
+# ---------------------------------------------------------------------------
+# Stdout display (backwards-compatible mode)
+# ---------------------------------------------------------------------------
+
+def print_email(eml_path):
+ """Parse and print email to stdout. Extract attachments alongside EML.
+
+ This preserves the original script behavior when --output-dir is not given.
+ """
+ with open(eml_path, 'rb') as f:
+ msg = email.message_from_binary_file(f)
+
+ metadata = extract_metadata(msg)
+ body = extract_body(msg)
+ timing = metadata['timing']
+
+ print(f"From: {metadata['from']}")
+ print(f"To: {metadata['to']}")
+ print(f"Subject: {metadata['subject']}")
+ print(f"Date: {metadata['date']}")
+ print(f"Sent: {timing['sent_time']} (via {timing['sent_server']})")
+ print(f"Received: {timing['received_time']} (at {timing['received_server']})")
print()
- print(body_text)
+ print(body)
print()
- # Extract attachments
- attachments = []
+ # Extract attachments alongside the EML file
for part in msg.walk():
if part.get_content_maintype() == 'multipart':
continue
@@ -37,17 +346,53 @@ def extract_attachments(eml_file):
filename = part.get_filename()
if filename:
- filepath = os.path.join(os.path.dirname(eml_file), filename)
+ filepath = os.path.join(os.path.dirname(eml_path), filename)
with open(filepath, 'wb') as f:
f.write(part.get_payload(decode=True))
- attachments.append(filename)
print(f"Extracted attachment: {filename}")
- return attachments
+
+def print_pipeline_summary(result):
+ """Print summary after pipeline extraction."""
+ metadata = result['metadata']
+ timing = metadata['timing']
+
+ print(f"From: {metadata['from']}")
+ print(f"To: {metadata['to']}")
+ print(f"Subject: {metadata['subject']}")
+ print(f"Date: {metadata['date']}")
+ print(f"Sent: {timing['sent_time']} (via {timing['sent_server']})")
+ print(f"Received: {timing['received_time']} (at {timing['received_server']})")
+ print()
+ print("Files created:")
+ for f in result['files']:
+ print(f" [{f['type']:>6}] {f['name']}")
+ print(f"\nOutput directory: {os.path.dirname(result['files'][0]['path'])}")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
if __name__ == "__main__":
- if len(sys.argv) < 2:
- print("Usage: extract_attachments.py <eml_file>")
+ parser = argparse.ArgumentParser(
+ description="Extract email content and attachments from EML files."
+ )
+ parser.add_argument('eml_path', help="Path to source EML file")
+ parser.add_argument(
+ '--output-dir',
+ help="Destination directory for extracted files. "
+ "Without this flag, prints to stdout only (backwards compatible)."
+ )
+
+ args = parser.parse_args()
+
+ if not os.path.isfile(args.eml_path):
+ print(f"Error: '{args.eml_path}' not found or is not a file.", file=sys.stderr)
sys.exit(1)
- extract_attachments(sys.argv[1])
+ if args.output_dir:
+ result = process_eml(args.eml_path, args.output_dir)
+ print_pipeline_summary(result)
+ else:
+ print_email(args.eml_path)
diff --git a/docs/scripts/tests/conftest.py b/docs/scripts/tests/conftest.py
new file mode 100644
index 0000000..8d965ab
--- /dev/null
+++ b/docs/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/docs/scripts/tests/fixtures/empty-body.eml b/docs/scripts/tests/fixtures/empty-body.eml
new file mode 100644
index 0000000..cf008df
--- /dev/null
+++ b/docs/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/docs/scripts/tests/fixtures/html-only.eml b/docs/scripts/tests/fixtures/html-only.eml
new file mode 100644
index 0000000..4db7645
--- /dev/null
+++ b/docs/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/docs/scripts/tests/fixtures/multiple-received-headers.eml b/docs/scripts/tests/fixtures/multiple-received-headers.eml
new file mode 100644
index 0000000..1b8d6a7
--- /dev/null
+++ b/docs/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/docs/scripts/tests/fixtures/no-received-headers.eml b/docs/scripts/tests/fixtures/no-received-headers.eml
new file mode 100644
index 0000000..8a05dc7
--- /dev/null
+++ b/docs/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/docs/scripts/tests/fixtures/plain-text.eml b/docs/scripts/tests/fixtures/plain-text.eml
new file mode 100644
index 0000000..8cc9d9c
--- /dev/null
+++ b/docs/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/docs/scripts/tests/fixtures/with-attachment.eml b/docs/scripts/tests/fixtures/with-attachment.eml
new file mode 100644
index 0000000..ac49c5d
--- /dev/null
+++ b/docs/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/docs/scripts/tests/test_extract_body.py b/docs/scripts/tests/test_extract_body.py
new file mode 100644
index 0000000..7b53cda
--- /dev/null
+++ b/docs/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/docs/scripts/tests/test_extract_metadata.py b/docs/scripts/tests/test_extract_metadata.py
new file mode 100644
index 0000000..d5ee52e
--- /dev/null
+++ b/docs/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/docs/scripts/tests/test_generate_filenames.py b/docs/scripts/tests/test_generate_filenames.py
new file mode 100644
index 0000000..07c8f84
--- /dev/null
+++ b/docs/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/docs/scripts/tests/test_integration_stdout.py b/docs/scripts/tests/test_integration_stdout.py
new file mode 100644
index 0000000..d87478e
--- /dev/null
+++ b/docs/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/docs/scripts/tests/test_parse_received_headers.py b/docs/scripts/tests/test_parse_received_headers.py
new file mode 100644
index 0000000..e12e1fb
--- /dev/null
+++ b/docs/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/docs/scripts/tests/test_process_eml.py b/docs/scripts/tests/test_process_eml.py
new file mode 100644
index 0000000..26c5ad5
--- /dev/null
+++ b/docs/scripts/tests/test_process_eml.py
@@ -0,0 +1,129 @@
+"""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 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/docs/scripts/tests/test_save_attachments.py b/docs/scripts/tests/test_save_attachments.py
new file mode 100644
index 0000000..32f02a6
--- /dev/null
+++ b/docs/scripts/tests/test_save_attachments.py
@@ -0,0 +1,97 @@
+"""Tests for save_attachments()."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from conftest import make_plain_message, make_message_with_attachment
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.mime.application import MIMEApplication
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+save_attachments = eml_script.save_attachments
+
+
+class TestSingleAttachment:
+ def test_file_written_and_returned(self, tmp_path):
+ msg = make_message_with_attachment(
+ attachment_filename="report.pdf",
+ attachment_content=b"pdf bytes here"
+ )
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+
+ assert len(result) == 1
+ assert result[0]['original_name'] == "report.pdf"
+ assert "ATTACH" in result[0]['renamed_name']
+ assert result[0]['renamed_name'].endswith(".pdf")
+
+ # File actually exists and has correct content
+ written_path = result[0]['path']
+ assert os.path.isfile(written_path)
+ with open(written_path, 'rb') as f:
+ assert f.read() == b"pdf bytes here"
+
+
+class TestMultipleAttachments:
+ def test_all_written_and_returned(self, tmp_path):
+ msg = MIMEMultipart()
+ msg['From'] = 'test@example.com'
+ msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600'
+ msg.attach(MIMEText("body", 'plain'))
+
+ for name, content in [("doc1.pdf", b"pdf1"), ("image.png", b"png1")]:
+ att = MIMEApplication(content, Name=name)
+ att['Content-Disposition'] = f'attachment; filename="{name}"'
+ msg.attach(att)
+
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+
+ assert len(result) == 2
+ for r in result:
+ assert os.path.isfile(r['path'])
+
+
+class TestNoAttachments:
+ def test_empty_list(self, tmp_path):
+ msg = make_plain_message()
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+ assert result == []
+
+
+class TestFilenameWithSpaces:
+ def test_cleaned_filename(self, tmp_path):
+ msg = make_message_with_attachment(
+ attachment_filename="My Document (1).pdf",
+ attachment_content=b"data"
+ )
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+
+ assert len(result) == 1
+ assert " " not in result[0]['renamed_name']
+ assert os.path.isfile(result[0]['path'])
+
+
+class TestNoContentDisposition:
+ def test_skipped(self, tmp_path):
+ msg = MIMEMultipart()
+ msg['From'] = 'test@example.com'
+ msg.attach(MIMEText("body", 'plain'))
+
+ # Add a part without Content-Disposition
+ part = MIMEApplication(b"data", Name="file.bin")
+ # Explicitly remove Content-Disposition if present
+ if 'Content-Disposition' in part:
+ del part['Content-Disposition']
+ msg.attach(part)
+
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+ assert result == []
diff --git a/docs/workflows/create-v2mom.org b/docs/workflows/create-v2mom.org
index 6a0cb87..d2c30e5 100644
--- a/docs/workflows/create-v2mom.org
+++ b/docs/workflows/create-v2mom.org
@@ -88,9 +88,13 @@ Trigger this V2MOM creation workflow when:
* Approach: How We Work Together
+** Phase 0: Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. This might be a long process. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
+
** Phase 1: Understand the V2MOM Framework
-Before starting, ensure both parties understand what each section means:
+Ensure both parties understand what each section means:
- *Vision:* What you want to achieve (aspirational, clear picture of success)
- *Values:* Principles that guide decisions (2-4 values, defined concretely)
@@ -216,6 +220,8 @@ Software Package V2MOM:
*Time estimate:* 45-90 minutes (longest section)
+Note: It would be a good to write out the session context file after each method. This helps prevent loss of data if the context auto-compacts.
+
** Phase 5.6: Brainstorm Additional Tasks for Each Method
Brainstorm what's missing to achieve the method.
@@ -447,6 +453,8 @@ Once the V2MOM feels complete:
*Why use immediately:* Validates the V2MOM is practical, not theoretical. Execution reveals gaps that discussion misses.
+Note: As before, write out the session context file to prevent any details from getting lost.
+
* Principles to Follow
** Honesty Over Aspiration
diff --git a/docs/workflows/create-workflow.org b/docs/workflows/create-workflow.org
index 9d2d739..85cd202 100644
--- a/docs/workflows/create-workflow.org
+++ b/docs/workflows/create-workflow.org
@@ -75,6 +75,9 @@ Examples:
- "Let's design a workflow for weekly planning"
* Approach: How We Work Together
+** Phase 0: Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. This might be a long process. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
** Phase 1: Question and Answer Discovery
@@ -222,7 +225,7 @@ Write the workflow file at =docs/workflows/[name].org= using this structure:
** Phase 5: Update Project State
-Update =NOTES.org=:
+Update =notes.org=:
1. Add new workflow to "Available Workflows" section
2. Include brief description and reference to file
3. Note creation date
@@ -239,7 +242,10 @@ Workflow for processing inbox to zero:
Created: 2025-11-01
#+end_src
-** Phase 6: Validate by Execution
+** Phase 6: Cleanup
+Write out the session context file before proceeding any further
+
+** Phase 7: Validate by Execution
*Critical step:* Use the workflow soon after creating it.
@@ -333,7 +339,7 @@ This very document was created using the process it describes (recursive!).
** The Q&A
- *Problem:* Time waste, errors, missed learning from informal processes
- *Exit criteria:* Logical arrangement, mutual understanding, agreement on effectiveness, actionable tasks
-- *Approach:* Four-question Q&A, assess completeness, name it, document it, update NOTES.org, validate by use
+- *Approach:* Four-question Q&A, assess completeness, name it, document it, update notes.org, validate by use
- *Principles:* Collaboration through discussion, review the whole, concrete over abstract, actionable tasks, validate early, decision frameworks, question assumptions
** The Result
diff --git a/docs/workflows/email-assembly.org b/docs/workflows/email-assembly.org
new file mode 100644
index 0000000..003459c
--- /dev/null
+++ b/docs/workflows/email-assembly.org
@@ -0,0 +1,183 @@
+#+TITLE: Email Assembly Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-01-29
+
+* Overview
+
+This workflow assembles documents for an email that will be sent via Craig's email client (Proton Mail). It creates a temporary workspace, gathers relevant documents, drafts the email, and cleans up after sending.
+
+Use this workflow when Craig needs to send an email with multiple attachments that require gathering from various locations in the project.
+
+* When to Use This Workflow
+
+When Craig says:
+- "assemble an email" or "email assembly workflow"
+- "gather documents for an email"
+- "I need to send [person] some documents"
+
+* The Workflow
+
+** Step 0: Context Window Hygiene
+- Write out the session context file.
+- Inform the user that you've written out the session context file and ask if they want to compact the context now before beginning.
+
+** Step 1: Create Temporary Workspace
+
+Create a temporary folder at the project root:
+
+#+begin_src bash
+mkdir -p ./tmp
+#+end_src
+
+This folder will hold:
+- Copies of all attachments
+- The draft email text
+
+** Step 2: Identify Required Documents
+
+Discuss with Craig what documents are needed. Common categories:
+- Legal documents (deeds, certificates, agreements)
+- Financial documents (statements, invoices)
+- Correspondence (prior emails, letters)
+- Identity documents (death certificates, ID copies)
+
+For each document:
+1. Locate it in the project
+2. Confirm with Craig it's the right one
+3. Open it in zathura for Craig to verify if needed
+
+** Step 3: Copy Documents to Workspace
+
+**IMPORTANT: Always COPY, never MOVE documents.**
+
+#+begin_src bash
+cp /path/to/original/document.pdf ./tmp/
+#+end_src
+
+After copying, list the workspace contents to confirm:
+
+#+begin_src bash
+ls -lh ./tmp/
+#+end_src
+
+** Step 4: Draft the Email
+
+Create a draft email file in the workspace:
+
+#+begin_src bash
+./tmp/email-draft.txt
+#+end_src
+
+Include:
+- To: (recipient email)
+- Subject: (clear, descriptive subject line)
+- Body: (context, list of attachments, contact info)
+
+The body should:
+- Provide context for why documents are being sent
+- List all attachments with brief descriptions
+- Include Craig's contact information
+
+** Step 5: Open Draft in Emacs
+
+Open the draft for Craig to review and edit:
+
+#+begin_src bash
+emacsclient -n ./tmp/email-draft.txt
+#+end_src
+
+Wait for Craig to finish editing before proceeding.
+
+** Step 6: Craig Sends Email
+
+Craig will:
+1. Open his email client (Proton Mail)
+2. Create a new email using the draft text
+3. Attach documents from the tmp folder
+4. Send the email
+
+** Step 7: Process Sent Email
+
+Once Craig confirms the email was sent:
+
+1. Craig saves the sent email to the project inbox
+2. Use the **extract-email workflow** to process it:
+ - Create extraction directory
+ - Copy email to extraction directory
+ - Run extraction script
+ - Rename with server timestamp: =YYYY-MM-DD_HHMMSS_description.ext=
+ - Refile to appropriate location
+ - Clean up extraction directory
+
+See [[file:extract-email.org][extract-email workflow]] for full details.
+
+** Step 8: Clean Up Workspace
+
+Delete the temporary folder:
+
+#+begin_src bash
+rm -rf ./tmp/
+#+end_src
+
+** Step 9: Update Context Window
+Update the session context file before exiting this workflow.
+
+* Best Practices
+
+** Document Verification
+
+Before copying documents:
+- Open each one in zathura for Craig to verify
+- Confirm it's the correct version
+- Check that sensitive information is appropriate to send
+
+** Email Draft Structure
+
+A good email draft includes:
+
+#+begin_example
+To: recipient@example.com
+Subject: [Clear Topic] - [Property/Case Reference]
+
+Hi [Name],
+
+[Opening - context for why you're sending this]
+
+[Middle - explanation of what's attached and why]
+
+Attached are the following documents:
+
+1. [Document name] - [brief description]
+2. [Document name] - [brief description]
+3. [Document name] - [brief description]
+
+[Closing - next steps, request for confirmation, offer to provide more]
+
+Thank you,
+
+Craig Jennings
+510-316-9357
+c@cjennings.net
+#+end_example
+
+** Filing Conventions
+
+When refiling sent emails:
+- Use format: =YYYY-MM-DD_HHMMSS_description.ext= (server timestamp)
+- File in the most relevant project folder (check project's notes.org for conventions)
+- Clean up extraction directory after refiling
+
+* Example Usage
+
+Craig: "I need to send Seabreeze the documents for the HOA refund"
+
+Claude:
+1. Creates ./tmp/ folder
+2. Discusses needed documents (death certificate, closing docs, purchase agreement)
+3. Locates and opens each document for verification
+4. Copies verified documents to ./tmp/
+5. Drafts email and opens in emacsclient
+6. Craig edits, then sends via Proton Mail
+7. Craig saves sent email to inbox
+8. Claude extracts, reads, renames, and refiles email
+9. Claude deletes ./tmp/ folder
diff --git a/docs/workflows/extract-email.org b/docs/workflows/extract-email.org
new file mode 100644
index 0000000..08464af
--- /dev/null
+++ b/docs/workflows/extract-email.org
@@ -0,0 +1,116 @@
+#+TITLE: Extract Email Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-02-06
+
+* Overview
+
+Extract email content and attachments from an EML file, rename with a consistent naming convention, and refile to =assets/=.
+
+* When to Use This Workflow
+
+When Craig says:
+- "extract the email"
+- "get the attachment from [email]"
+- "pull the info from [email]"
+- "process the email in inbox"
+
+* Sources
+
+The EML file may come from two places:
+
+** Already in =inbox/=
+
+Emails dropped into the project's =inbox/= directory via Syncthing, manual copy, or other means. These are ready for extraction immediately.
+
+** From =~/.mail/=
+
+Emails in the local maildir managed by mbsync/mu. Use the [[file:find-email.org][find-email workflow]] to locate the message, then copy (don't move) it into =inbox/= before proceeding. Never modify =~/.mail/= directly.
+
+* The Workflow
+
+** Step 0: Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. If there are a lot of emails, this will be a long process. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
+
+** Step 1: Run Extraction Script
+
+Run the extraction script with =--output-dir= to perform the full pipeline (create temp dir, parse, auto-rename, extract attachments, refile, clean up):
+
+#+begin_src bash
+python3 docs/scripts/eml-view-and-extract-attachments.py inbox/message.eml --output-dir assets/
+#+end_src
+
+The script automatically:
+- Parses email headers, body, and attachments
+- Generates filenames using the naming convention (see below)
+- Creates =.eml= (renamed copy), =.txt= (body text), and attachment files
+- Checks for filename collisions in the output directory
+- Moves all files to =assets/=
+- Cleans up its temp directory
+- Prints a summary of created files
+
+** Step 2: Review Summary Output
+
+Review the script's summary output and verify:
+- Filenames look correct (rename manually if needed)
+- Delete junk attachments (e.g., signature logos, tracking pixels)
+- Delete source EML from inbox after confirming results
+
+** Step 3: Report Results
+
+Report to Craig:
+- Summary of email content
+- What files were extracted and their final names
+- Where files were saved
+
+* Naming Convention
+
+Pattern: =YYYY-MM-DD-HHMM-Sender-TYPE-Description.ext=
+
+| Component | Source |
+|-------------+---------------------------------------------------------------------------|
+| YYYY-MM-DD | From the email's Date header (server time) |
+| HHMM | Hours and minutes from the Date header |
+| Sender | First name of the sender |
+| TYPE | =EMAIL= for the email body (.eml and .txt), =ATTACH= for attachments |
+| Description | Shortened subject line for EMAIL files; original filename for ATTACH files |
+
+** Example
+
+For an email from Jonathan Smith, subject "Re: Fw: 4319 Danneel Street", sent 2026-02-05 at 11:36, with a PDF attachment "Ltr Carrollton.pdf":
+
+#+begin_src
+2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.eml
+2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.txt
+2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf
+#+end_src
+
+* Backwards-Compatible Mode
+
+Without =--output-dir=, the script behaves as before: prints metadata and body to stdout, extracts attachments alongside the EML file. This is useful for quick inspection without filing.
+
+#+begin_src bash
+python3 docs/scripts/eml-view-and-extract-attachments.py inbox/message.eml
+#+end_src
+
+* Batch Processing
+
+When processing multiple emails, complete all steps for one email before starting the next. Do not parallelize across emails.
+
+* Principles
+
+- *Never modify =~/.mail/=* — always copy first, work on the copy
+- *EML is authoritative* — always keep it alongside extracted files
+- *Use email Date header for timestamps* — not extraction time
+- *Refer to find-email for maildir searches* — don't duplicate those instructions
+- *Script checks for collisions* — won't overwrite existing files in output dir
+- *One email at a time* — complete the full cycle before starting the next
+- *Source EML stays untouched* — the script copies, never moves the source; Claude deletes after verifying results
+
+* Tools Reference
+
+| Tool | Purpose |
+|-------------------------------------+---------------------------------|
+| eml-view-and-extract-attachments.py | Extract content and attachments |
+
+Script location: =docs/scripts/eml-view-and-extract-attachments.py=
diff --git a/docs/workflows/find-email.org b/docs/workflows/find-email.org
new file mode 100644
index 0000000..0ef9615
--- /dev/null
+++ b/docs/workflows/find-email.org
@@ -0,0 +1,122 @@
+#+TITLE: Find Email Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-02-01
+
+* Overview
+
+This workflow searches local maildir to find and identify emails matching specific criteria. Uses mu (maildir indexer) for fast searching.
+
+* Problem We're Solving
+
+Craig needs to find specific emails - shipping confirmations, receipts, correspondence with specific people, or messages about specific topics. Manually browsing mail folders is slow and error-prone. mu provides powerful search capabilities over the local maildir.
+
+* Exit Criteria
+
+Search is complete when:
+1. Matching emails are identified (or confirmed none exist)
+2. Relevant information is reported (subject, date, from, message path)
+3. Craig has what they need to proceed (info extracted, or path for further action)
+
+* When to Use This Workflow
+
+When Craig says:
+- "find email about [topic]"
+- "search for emails from [person]"
+- "do I have an email about [subject]?"
+- "look for [shipping/receipt/confirmation] email"
+- Before extract-email workflow (to locate the target email)
+
+* The Workflow
+** Step 0: Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. This might be a long process. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
+
+** Step 1: Ensure Mail is Current (Optional)
+
+If searching for recent emails, run sync-email workflow first:
+
+#+begin_src bash
+mbsync -a && mu index
+#+end_src
+
+Skip if Craig confirms mail is already synced.
+
+** Step 2: Construct Search Query
+
+mu supports powerful search syntax:
+
+#+begin_src bash
+# By sender
+mu find from:jdslabs.com
+
+# By subject
+mu find subject:shipped
+
+# By date range
+mu find date:2w..now # last 2 weeks
+mu find date:2026-01-01.. # since Jan 1
+
+# Combined queries
+mu find from:fedex subject:tracking date:1w..now
+
+# In specific folder
+mu find maildir:/gmail/INBOX from:amazon
+
+# Full text search
+mu find "order confirmation"
+#+end_src
+
+** Step 3: Run Search
+
+#+begin_src bash
+mu find [query]
+#+end_src
+
+Default output shows: date, from, subject, path
+
+For more detail:
+#+begin_src bash
+mu find --fields="d f s l" [query] # date, from, subject, path
+mu find --sortfield=date --reverse [query] # newest first
+#+end_src
+
+** Step 4: Report Results
+
+Report to Craig:
+- Number of matches found
+- Key details (date, from, subject) for relevant matches
+- Message path if Craig needs to extract or read it
+
+If no matches:
+- Confirm the search was correct
+- Suggest alternative search terms
+- Consider if mail needs syncing first
+
+* Search Query Reference
+
+| Field | Example | Notes |
+|----------+------------------------------+--------------------------|
+| from: | from:amazon.com | Sender address/domain |
+| to: | to:c@cjennings.net | Recipient |
+| subject: | subject:"order shipped" | Subject line |
+| body: | body:tracking | Message body |
+| date: | date:1w..now | Relative or absolute |
+| flag: | flag:unread | unread, flagged, etc. |
+| maildir: | maildir:/gmail/INBOX | Specific folder |
+| mime: | mime:application/pdf | Has attachment type |
+
+Combine with AND (space), OR (or), NOT (not):
+#+begin_src bash
+mu find from:amazon subject:shipped not subject:delayed
+#+end_src
+
+* Principles
+
+- **Sync first if needed** - Searching stale mail misses recent messages
+- **Start broad, narrow down** - Better to find too many than miss the target
+- **Use date ranges** - Dramatically speeds up searches for recent mail
+- **Report paths** - Message paths enable extract-email workflow
+
+* Living Document
+
+Update this workflow as we discover useful search patterns.
diff --git a/docs/workflows/journal-entry.org b/docs/workflows/journal-entry.org
index 2a184af..c7057de 100644
--- a/docs/workflows/journal-entry.org
+++ b/docs/workflows/journal-entry.org
@@ -34,7 +34,7 @@ Without regular journal entries, several problems emerge:
We know a journal entry is complete when:
-1. **Draft has been created** - Claude writes initial first-person draft based on NOTES.org session data
+1. **Draft has been created** - Claude writes initial first-person draft based on notes.org session data
2. **Revisions are complete** - Craig provides corrections and context until satisfied
3. **Entry is added to journal file** - Text is added to the org-roam daily journal at ~/sync/org/roam/journal/YYYY-MM-DD.org
4. **Craig approves** - Craig explicitly approves or indicates no more revisions needed
@@ -57,10 +57,13 @@ Trigger this workflow when:
This is typically done at the end of the day to capture that day's activities.
* Approach: How We Work Together
+** Step 0: Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
** Step 1: Review the Day's Work
-Check the project's NOTES.org file for today's session entries. This is your primary source for:
+Check the project's notes.org file for today's session entries. This is your primary source for:
- Accomplishments achieved
- Decisions made
- Meetings or calls attended
@@ -126,7 +129,9 @@ Once approved:
** Step 6: Wrap Up
-After adding the journal entry, ask Craig: "Are we done for the evening, or is there anything else that needs to be done?"
+Update the session context file.
+
+After updating the session context file, ask Craig: "Are we done for the evening, or is there anything else that needs to be done?"
Since journal entries typically happen at end of day, this provides a natural session close.
@@ -161,7 +166,7 @@ Since journal entries typically happen at end of day, this provides a natural se
- Save insights for future reference
** Use Session Data
-- Start from NOTES.org session entries for the day
+- Start from notes.org session entries for the day
- Don't rely on memory - check the documented record
- Include key decisions, accomplishments, and next steps
@@ -178,7 +183,7 @@ Every journal entry is an opportunity to improve this workflow.
* Example Journal Entry
-Here's an example from 2025-11-07 (JR-Estate project):
+Here's an example of the tone, narrative flow, and level of detail to aim for:
#+begin_quote
Big day. We sold Gogo's condo.
diff --git a/docs/workflows/refactor.org b/docs/workflows/refactor.org
index 0adb337..9e967b8 100644
--- a/docs/workflows/refactor.org
+++ b/docs/workflows/refactor.org
@@ -14,6 +14,10 @@ This document describes a comprehensive test-driven quality engineering workflow
4. Demonstrate refactoring patterns for testability
5. Document the decision-making process for test vs production code issues
+* Phase 0: Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
+
* Phase 1: Feature Addition with Testability in Mind
** The Feature Request
diff --git a/docs/workflows/retrospective-workflow.org b/docs/workflows/retrospective.org
index 440c14e..c512cfb 100644
--- a/docs/workflows/retrospective-workflow.org
+++ b/docs/workflows/retrospective.org
@@ -11,6 +11,10 @@ Run after:
* The Process
+** 0. Context Hygiene
+
+Before starting, write out the session context file and check with Craig whether we could compact the context. If the context window collapses, we may forget important details. Writing out the session context prevents this data loss.
+
** 1. Trigger the Retrospective
Either party can say: "Let's do a retrospective" or "Retrospective time"
diff --git a/docs/workflows/send-email.org b/docs/workflows/send-email.org
new file mode 100644
index 0000000..cfd7adf
--- /dev/null
+++ b/docs/workflows/send-email.org
@@ -0,0 +1,198 @@
+#+TITLE: Email Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-01-26
+
+* Overview
+
+This workflow sends emails with optional attachments via msmtp using the cmail account (c@cjennings.net via Proton Bridge).
+
+* When to Use This Workflow
+
+When Craig says:
+- "email workflow" or "send an email"
+- "email [person] about [topic]"
+- "send [file] to [person]"
+
+* Required Information
+
+Before sending, gather and confirm:
+
+1. **To:** (required) - recipient email address(es)
+2. **CC:** (optional) - carbon copy recipients
+3. **BCC:** (optional) - blind carbon copy recipients
+4. **Subject:** (required) - email subject line
+5. **Body:** (required) - email body text
+6. **Attachments:** (optional) - file path(s) to attach
+
+* The Workflow
+
+** Step 1: Gather Missing Information
+
+If any required fields are missing, prompt Craig:
+
+#+begin_example
+To send this email, I need:
+- To: [who should receive this?]
+- Subject: [what's the subject line?]
+- Body: [what should the email say?]
+- Attachments: [any files to attach?]
+- CC/BCC: [anyone to copy?]
+#+end_example
+
+** Step 2: Validate Email Addresses
+
+Look up all recipient names/emails in the contacts file:
+
+#+begin_src bash
+grep -i "[name or email]" ~/sync/org/contacts.org
+#+end_src
+
+**Note:** If contacts.org is empty, check for sync-conflict files:
+#+begin_src bash
+ls ~/sync/org/contacts*.org
+#+end_src
+
+For each recipient:
+1. Search contacts by name or email
+2. Confirm the email address matches
+3. If name not found, ask Craig to confirm the email is correct
+4. If multiple emails for a contact, ask which one to use
+
+** Step 3: Confirm Before Sending
+
+Display the complete email for review:
+
+#+begin_example
+Ready to send:
+
+From: c@cjennings.net
+To: [validated email(s)]
+CC: [if any]
+BCC: [if any]
+Subject: [subject]
+
+[body text]
+
+Attachments: [list files if any]
+
+Send this email? [Y/n]
+#+end_example
+
+** Step 4: Send the Email
+
+Use Python to construct MIME message and pipe to msmtp:
+
+#+begin_src python
+python3 << 'EOF' | msmtp -a cmail [recipient]
+import sys
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.mime.application import MIMEApplication
+from email.utils import formatdate
+import os
+
+msg = MIMEMultipart()
+msg['From'] = 'c@cjennings.net'
+msg['To'] = '[to_address]'
+# msg['Cc'] = '[cc_address]' # if applicable
+# msg['Bcc'] = '[bcc_address]' # if applicable
+msg['Subject'] = '[subject]'
+msg['Date'] = formatdate(localtime=True)
+
+body = """[body text]"""
+msg.attach(MIMEText(body, 'plain'))
+
+# For each attachment:
+# pdf_path = '/path/to/file.pdf'
+# with open(pdf_path, 'rb') as f:
+# attachment = MIMEApplication(f.read(), _subtype='pdf')
+# attachment.add_header('Content-Disposition', 'attachment', filename='filename.pdf')
+# msg.attach(attachment)
+
+print(msg.as_string())
+EOF
+#+end_src
+
+**Important:** When there are CC or BCC recipients, pass ALL recipients to msmtp:
+#+begin_src bash
+python3 << 'EOF' | msmtp -a cmail to@example.com cc@example.com bcc@example.com
+#+end_src
+
+** Step 5: Verify Delivery
+
+Check the msmtp log for confirmation:
+
+#+begin_src bash
+tail -3 ~/.msmtp.cmail.log
+#+end_src
+
+Look for: ~smtpstatus=250~ and ~exitcode=EX_OK~
+
+** Step 6: Sync to Sent Folder (Optional)
+
+If Craig wants the email in his Sent folder:
+
+#+begin_src bash
+mbsync cmail
+#+end_src
+
+* msmtp Configuration
+
+The cmail account should be configured in ~/.msmtprc:
+
+#+begin_example
+account cmail
+tls_certcheck off
+auth on
+host 127.0.0.1
+port 1025
+protocol smtp
+from c@cjennings.net
+user c@cjennings.net
+passwordeval "cat ~/.config/.cmailpass"
+tls on
+tls_starttls on
+logfile ~/.msmtp.cmail.log
+#+end_example
+
+**Note:** ~tls_certcheck off~ is used because Proton Bridge uses self-signed certificates on localhost.
+
+* Attachment Handling
+
+** Supported Types
+
+Common MIME subtypes:
+- PDF: ~_subtype='pdf'~
+- Images: ~_subtype='png'~, ~_subtype='jpeg'~
+- Text: ~_subtype='plain'~
+- Generic: ~_subtype='octet-stream'~
+
+** Multiple Attachments
+
+Add multiple attachment blocks before ~print(msg.as_string())~
+
+* Troubleshooting
+
+** Password File Missing
+Ensure ~/.config/.cmailpass exists with the Proton Bridge SMTP password.
+
+** TLS Certificate Errors
+Use ~tls_certcheck off~ in msmtprc for Proton Bridge (localhost only).
+
+** Proton Bridge Not Running
+Start Proton Bridge before sending. Check if port 1025 is listening:
+#+begin_src bash
+ss -tlnp | grep 1025
+#+end_src
+
+* Example Usage
+
+Craig: "email workflow - send the November 3rd SOV to Christine"
+
+Claude:
+1. Searches contacts for "Christine" -> finds cciarmello@gmail.com
+2. Asks for subject and body if not provided
+3. Locates the SOV file in assets/
+4. Shows confirmation
+5. Sends via msmtp
+6. Verifies delivery in log
diff --git a/docs/workflows/session-start.org b/docs/workflows/session-start.org
deleted file mode 100644
index 66973ab..0000000
--- a/docs/workflows/session-start.org
+++ /dev/null
@@ -1,540 +0,0 @@
-#+TITLE: Session Start Workflow
-#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2025-11-14
-
-* Overview
-
-This workflow defines the process for beginning a Claude Code session. It ensures Claude has the necessary context, is aware of current priorities, and can work effectively with Craig from the start.
-
-This workflow runs **automatically at the beginning of EVERY session** without needing to be triggered by Craig.
-
-* Problem We're Solving
-
-Without a structured session start process, sessions can begin poorly:
-
-** Missing Context
-- Claude doesn't know what happened in previous sessions
-- Important reminders or decisions are overlooked
-- No awareness of project-specific conventions or preferences
-- Time wasted re-explaining project context
-
-** Outdated Information
-- Missing protocol updates from template improvements
-- Unaware of new workflows available in the project
-- Using old conventions that have been superseded
-- Templates and project drift out of sync
-
-** Missed Priorities
-- New documents in inbox go unnoticed
-- Critical reminders aren't surfaced
-- Pending decisions remain unaddressed
-- User has urgent work but Claude suggests non-urgent tasks
-
-** Inefficient Start
-- Reading entire NOTES.org file (wasting tokens on static content)
-- No clear next steps or priorities
-- User must orient Claude manually
-- Lack of structure for beginning work
-
-*Impact:* Slow session starts, missed context, wasted time, and frustration from having to repeat information.
-
-* Exit Criteria
-
-The session start is complete when:
-
-1. **Claude has current context:**
- - Knows project-specific information and goals
- - Aware of how user prefers to work
- - Understanding of recent session history (last 2-3 sessions)
- - Current reminders and pending decisions surfaced
-
-2. **Templates are synced:**
- - Checked for updates from claude-templates
- - Any new workflows or improvements identified
- - Project templates directory is current
-
-3. **Inbox is processed:**
- - New documents identified and reviewed
- - Recommendations provided for each
- - Documents filed appropriately
-
-4. **Next steps are clear:**
- - User has stated urgent work, OR
- - Claude has suggested following what's-next workflow
- - Session has clear direction
-
-*Measurable validation:*
-- Active Reminders have been surfaced to user
-- Pending Decisions have been mentioned
-- Inbox is empty or user has deferred processing
-- User has confirmed what to work on OR agreed to what's-next workflow
-
-* When to Use This Workflow
-
-**IMPORTANT: This workflow runs AUTOMATICALLY at the start of EVERY session.**
-
-Do NOT ask the user if they want to run it - just execute it immediately when a new session begins.
-
-The only exception is if the user immediately provides urgent instructions that require immediate action (e.g., "the server is down, help me debug it right now").
-
-* First Time Setup
-
-If this is the first time using this workflow in a project, perform this one-time cleanup:
-
-** Clean Up Old "Session Start" Documentation
-
-Before using this workflow, check if NOTES.org contains old/redundant session start instructions:
-
-1. **Search NOTES.org for duplicate content:**
- #+begin_src bash
- grep -n "Session Start\|session start" docs/NOTES.org
- #+end_src
-
-2. **Look for sections that duplicate this workflow:**
- - Detailed "Session Start Routine" instructions
- - Step-by-step template sync instructions
- - Inline instructions for reading NOTES.org sections
- - Inbox processing instructions
-
-3. **Replace with link to this workflow:**
- - Remove the detailed inline instructions
- - Replace with: "See [[file:docs/workflows/session-start.org][Session Start Workflow]]"
- - Keep brief summary if helpful for quick reference
-
-4. **Why this matters:**
- - Prevents conflicting instructions (e.g., different diff commands)
- - Single source of truth for session start process
- - Easier to maintain and update in one place
- - Reduces NOTES.org file size
-
-**Example cleanup:**
-
-Before:
-#+begin_example
-* Session Start Routine
-
-When starting a new session with Craig, follow these steps:
-
-1. Add Timestamp for Session History
- [detailed instructions...]
-2. Sync with Templates
- [detailed instructions...]
-3. Scan workflows directory
- [detailed instructions...]
-[30+ lines of detailed instructions]
-#+end_example
-
-After:
-#+begin_example
-* Session Start Routine
-
-**IMPORTANT: ALWAYS run this routine automatically at the start of EVERY session.**
-
-Execute the session start workflow defined in [[file:docs/workflows/session-start.org][session-start.org]].
-
-Brief summary: Add timestamp, sync templates, scan workflows, read key NOTES.org sections, process inbox, ask about priorities.
-#+end_example
-
-This cleanup should only be done ONCE when first adopting this workflow.
-
-* The Workflow
-
-** Step 1: Add Session Start Timestamp
-
-Record when the session begins for tracking purposes.
-
-*** Why This Matters
-
-- Helps identify if previous session was interrupted (no end timestamp)
-- Provides session duration tracking
-- Creates clear session boundaries in history
-
-*** How to Add Timestamp
-
-1. **Navigate to Session History section** in =docs/NOTES.org=
-
-2. **Check the last session entry:**
- - If it has a start timestamp but NO end timestamp → previous session was interrupted
- - If it has both start and end timestamps → previous session completed normally
-
-3. **Prepare for new session entry** (will be written during wrap-up):
- - Note the current time for internal reference
- - Session entry will be created at wrap-up, not now
- - This step is just to CHECK for interrupted sessions
-
-4. **If previous session was interrupted:**
- - Mention to user: "I notice the last session (DATE) didn't complete normally. Should I review what was being worked on?"
- - This helps recover context if something went wrong
-
-*** Format
-
-Session timestamps follow this format (added during wrap-up, not session start):
-- Start: Recorded at beginning of session
-- End: Recorded during wrap-up workflow
-
-Example:
-#+begin_example
-** Session: November 14, 2025 (Morning)
-Started: 09:30 AM
-Ended: 11:45 AM
-#+end_example
-
-** Step 2: Sync with Templates
-
-Keep the project aligned with latest best practices from claude-templates.
-
-*** CRITICAL: Template is Authoritative
-
-**The template is the single source of truth.** Do not review diffs or decide whether to apply changes - just copy from template to project. There should NEVER be differences between template files and project files, not even cosmetic differences like trailing whitespace.
-
-If a project needs project-specific protocol additions, put them in NOTES.org under a "Project-Specific Protocols" section - NEVER modify protocols.org in the project.
-
-*** Why This Matters
-
-- Ensures consistent behavior across all projects
-- Eliminates drift between template and projects
-- Protocol improvements automatically propagate to all projects
-- Single source of truth prevents confusion
-
-*** How to Copy
-
-Copy template files directly to the project's docs/ directory:
-
-#+begin_src bash
-cp ~/projects/claude-templates/docs/protocols.org docs/protocols.org
-cp -r ~/projects/claude-templates/docs/workflows docs/
-cp -r ~/projects/claude-templates/docs/scripts docs/
-#+end_src
-
-**Mention any significant updates** if you notice them:
-- If protocols.org has new sections, mention briefly
-- If new workflows were added, mention them
-- Example: "protocols.org now includes alarm instructions"
-
-That's it. No diff review, no decisions - template always wins.
-
-*** Workflow Directory Structure
-
-There are TWO workflow directories:
-
-**=docs/workflows/=** - Template workflows (standard across all projects)
-- Copied from template on every session start
-- NEVER edit these in a project - edit in template instead
-- Safe to overwrite completely during copy
-- Examples: session-start.org, session-wrap-up.org, create-workflow.org
-
-**=docs/project-workflows/=** - Project-specific workflows (unique to this project)
-- NEVER copied or touched by template sync
-- Created and maintained within the project only
-- Examples: morning-checkin.org (health), danneel-inbox-zero.org (danneel)
-
-*** What Gets Copied (Template → Project)
-
-- **protocols.org** - Always overwrite from template
-- **workflows/** - Template workflows (overwritten completely)
-- **scripts/** - Template scripts (overwritten completely)
-
-*** What Is NEVER Copied (Project-Specific)
-
-- **NOTES.org** - Project-specific content (context, history, reminders)
-- **previous-session-history.org** - Project-specific history
-- **session-context.org** - Project-specific live session data
-- **someday-maybe.org** - Project-specific ideas
-- **project-workflows/** - Project-specific workflows (separate directory, never touched)
-
-** Step 3: Scan Workflows Directory
-
-Understand what workflows are available in this project.
-
-*** Why This Matters
-
-- Enables Claude to suggest appropriate workflows
-- Builds vocabulary for understanding user requests
-- Shows what standardized processes exist
-- Helps answer "how do we do X?" questions
-
-*** How to Scan
-
-1. **List workflow files:**
- #+begin_src bash
- ls -1 docs/workflows/
- #+end_src
-
-2. **Note the workflow names** (just filenames, don't read contents):
- - create-workflow.org
- - create-v2mom.org
- - session-start.org
- - session-wrap-up.org
- - [project-specific workflows]
-
-3. **Remember these exist** for suggesting to user later
-
-4. **Do NOT read full workflow contents** - they're long and will be read when actually executing a workflow
-
-*** When User Says "Let's Run the X Workflow"
-
-1. Check if workflow exists in docs/workflows/
-2. If yes: Read that specific workflow file and execute it
-3. If no: Offer to create it using create-workflow process
-
-** Step 4: Read protocols.org and NOTES.org
-
-Get behavioral instructions and project context.
-
-*** Why Two Files?
-
-Documentation is now split into two files:
-- **protocols.org** - How Claude should behave (consistent across all projects)
-- **NOTES.org** - Project-specific content (unique to each project)
-
-This separation allows protocol updates to sync automatically without affecting project data.
-
-*** Read protocols.org First
-
-Read the entire =docs/protocols.org= file. It contains:
-- Session management protocols (context files, compacting)
-- Important terminology and trigger phrases
-- User information and preferences
-- Git commit requirements
-- File format and naming conventions
-- Session start instructions
-
-This file is relatively short and should be read in full.
-
-*** Then Read NOTES.org
-
-NOTES.org now contains only project-specific content:
-
-1. **Project-Specific Context** section
- - **THIS IS THE KEY SECTION** - contains what the project is about
- - People involved and their roles
- - Current state of the project
- - Goals and objectives
- - Important background information
- - Technical architecture or key decisions
-
-2. **Active Reminders** section
- - Critical items to surface to user
- - Time-sensitive follow-ups
- - Important tasks user asked to be reminded about
-
-3. **Pending Decisions** section
- - Decisions awaiting user input
- - Blockers that need resolution
- - Questions that need answering
-
-4. **Session History - Last 2-3 entries only**
- - Recent work completed
- - Recent decisions made
- - Context from previous sessions
- - What was being worked on
- - **Do NOT read entire session history** - older entries are archived
-
-*** What to SKIP in NOTES.org
-
-- About This File (brief, just links to protocols.org)
-- Available Workflows catalog (just scanned in Step 3)
-- Session History format instructions (reference material)
-- Full Session History (only read last 2-3 entries)
-- Archived sessions in previous-session-history.org (unless specifically needed)
-
-*** How to Read Efficiently
-
-#+begin_example
-1. Read docs/protocols.org (entire file - it's the behavioral guide)
-2. Read docs/NOTES.org Project-Specific Context section
-3. Read docs/NOTES.org Active Reminders section
-4. Read docs/NOTES.org Pending Decisions section
-5. Read last 2-3 Session History entries (count backwards from end)
-#+end_example
-
-** Step 5: Process Inbox
-
-Check for new documents and help user file them appropriately.
-
-*** Why This Matters
-
-- New documents need to be reviewed and filed
-- Unprocessed inbox items can contain important information
-- Helps maintain organized project structure
-- Ensures nothing gets missed
-
-*** Inbox Location
-
-Default inbox location: =./inbox/= (at project root)
-
-Some projects may have different locations - check NOTES.org or ask user on first session.
-
-*** How to Process
-
-1. **Check inbox directory:**
- #+begin_src bash
- ls -lh inbox/
- #+end_src
-
-2. **If inbox is empty:**
- - No action needed
- - Continue to Step 6
-
-3. **If inbox contains files:**
- - **Do NOT ask if user wants to process** - process is MANDATORY
- - Say: "I see X items in your inbox. Let me review them."
-
-4. **For each file in inbox:**
- - Read the file to understand content
- - Determine:
- - Who it's from (if applicable)
- - What it's about
- - What action should be taken
- - Consider project history and context
- - Recommend appropriate action:
- - File to =assets/emails/= if it's an email
- - File to =assets/= if it's a document/attachment
- - File to =docs/= if it's project documentation
- - Add TODO items if action is needed
- - Recommend appropriate filename:
- - Format: =YYYY-MM-DD-Source-Description.ext=
- - Example: =2025-11-14-Mark-Response-To-Settlement.txt=
- - Get user approval before filing
-
-5. **File the document:**
- - Move (not copy) from inbox to destination
- - Use approved filename
- - Update any relevant TODO items or event logs
-
-6. **Repeat for all inbox items** until inbox is empty
-
-*** Inbox Processing Workflow
-
-Some projects may have a specific inbox-zero workflow (e.g., =docs/workflows/danneel-inbox-zero.org=).
-
-If it exists:
-- Use that workflow instead of the generic process above
-- It may have project-specific filing conventions
-
-** Step 6: Ask About Priorities
-
-Determine what to work on this session.
-
-*** Why This Matters
-
-- User may have urgent work that takes priority
-- Prevents suggesting V2MOM tasks when there's a crisis
-- Gives user control over session direction
-- Enables following structured workflow if no urgent work
-
-*** How to Ask
-
-**Say to user:**
-
-"Is there something urgent you'd like to work on, or should we follow the what's-next workflow to identify priorities?"
-
-Wait for user response.
-
-*** If User Has Urgent Work
-
-- Proceed with the urgent work immediately
-- Skip what's-next workflow
-- Focus session on user's stated priority
-
-*** If User Says Follow What's-Next
-
-- Check if =docs/workflows/whats-next.org= exists
-- If yes: Execute that workflow
-- If no: Check if V2MOM exists and suggest working on top priority method
-- If no V2MOM: Offer to create one using create-v2mom workflow
-
-*** If User is Unsure
-
-Provide helpful context:
-- Remind them of Active Reminders (from Step 4)
-- Mention any Pending Decisions (from Step 4)
-- Reference recent session history (what was being worked on)
-- Suggest continuing previous work if it's unfinished
-
-** Session Start Complete
-
-Once all steps are done:
-- Claude has full context
-- User knows what's being worked on
-- Session can proceed productively
-
-Don't announce "session start complete" - just begin working on the chosen task.
-
-* Tips for Effective Session Starts
-
-** Template Sync
-
-1. **Don't panic about changes** - Most are improvements
-2. **Review carefully** - Understand what changed before applying
-3. **Project-specific workflows** - Don't overwrite if customized
-4. **Mention significant updates** - User should know about workflow improvements
-5. **First sync is clean** - No diff needed, just copy
-
-** Reading NOTES.org
-
-1. **Use line numbers** - More efficient than searching
-2. **Read in order** - Builds context progressively
-3. **Focus on Project-Specific Context** - This is the most important section
-4. **Surface reminders immediately** - Don't wait until later
-5. **Note interrupted sessions** - Helps recover from crashes
-
-** Inbox Processing
-
-1. **Be thorough** - Read files completely to understand context
-2. **Consider history** - Recommendations should fit project patterns
-3. **Suggest good filenames** - Use established conventions
-4. **Don't delete without asking** - User may want to review first
-5. **Update related systems** - Add TODOs, update event logs as needed
-
-** Priorities Discussion
-
-1. **Ask clearly** - Don't be vague about what's being offered
-2. **Respect urgency** - If user has crisis, drop everything else
-3. **Provide context** - Remind user of reminders and decisions
-4. **Don't assume** - Even if previous session had direction, ask
-5. **Be helpful** - If user is unsure, provide gentle guidance
-
-* Common Mistakes to Avoid
-
-1. **Reading entire NOTES.org file** - Wastes tokens on static content
-2. **Skipping template sync** - Miss important updates
-3. **Not checking for interrupted sessions** - Lose context from crashes
-4. **Forgetting to surface Active Reminders** - User misses critical items
-5. **Asking if user wants inbox processed** - It's mandatory, not optional
-6. **Processing inbox after asking about priorities** - Should be before
-7. **Not excluding NOTES.org from template diff** - Creates noise
-8. **Not excluding previous-session-history.org from diff** - Project-specific content
-9. **Announcing "session start complete"** - Just begin working
-10. **Reading full workflow files during scan** - Just note filenames
-
-* Validation Checklist
-
-Before considering session start complete, verify:
-
-- [ ] Checked for interrupted previous session (missing end timestamp)
-- [ ] Template sync executed (diff or first-time copy)
-- [ ] Template changes reviewed and applied if needed
-- [ ] Workflows directory scanned (filenames noted)
-- [ ] IMPORTANT TERMINOLOGY section read
-- [ ] User Information section read
-- [ ] Project-Specific Context section read (CRITICAL)
-- [ ] Active Reminders section read and surfaced to user
-- [ ] Pending Decisions section read and mentioned to user
-- [ ] Last 2-3 Session History entries read
-- [ ] Inbox checked for new files
-- [ ] Inbox processed (if contained files) or confirmed empty
-- [ ] Asked user about urgent work vs what's-next workflow
-- [ ] User has confirmed what to work on OR agreed to follow workflow
-- [ ] Ready to begin productive work
-
-* Meta Notes
-
-This workflow should evolve based on experience:
-
-- If session starts consistently miss something important, update the checklist
-- If reading NOTES.org sections takes too long, further optimize what's read
-- If template sync creates too much noise, refine what's excluded
-- Craig can modify this workflow at any time to better fit needs
-
-The goal is efficient session starts with full context, not rigid adherence to format.
diff --git a/docs/workflows/set-alarm.org b/docs/workflows/set-alarm.org
new file mode 100644
index 0000000..440e769
--- /dev/null
+++ b/docs/workflows/set-alarm.org
@@ -0,0 +1,165 @@
+#+TITLE: Set Alarm Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-01-31
+#+UPDATED: 2026-02-02
+
+* Overview
+
+This workflow enables Claude to set timers and alarms that reliably notify Craig, even if the terminal session ends or is accidentally closed. Notifications are distinctive (audible + visual with alarm icon) and persist until manually dismissed.
+
+Uses the =notify= command (alarm type) for consistent notifications across all AI workflows.
+
+* Problem We're Solving
+
+Notifications from AI sessions have several issues:
+
+1. *Too easy to miss* - Among many dunst notifications, AI alerts blend in
+2. *Not audible* - Dunst notifications are visual-only by default
+3. *Lost on terminal close* - If the terminal is accidentally closed, scheduled notifications never fire
+
+*Impact:* Missed notifications lead to lost time and reduced productivity. Tasks that depend on timely reminders get forgotten or delayed.
+
+* Exit Criteria
+
+The workflow is successful when:
+
+1. AI-set alarms are never missed
+2. Notifications are immediately noticeable, even when away from desk (audible)
+3. Notifications persist until manually dismissed (no auto-fade)
+4. Alarms fire regardless of whether the Claude session has ended or terminal was closed
+
+* When to Use This Workflow
+
+Use this workflow when:
+
+- Craig asks you to remind him about something at a specific time
+- A long-running task needs a check-in notification
+- Craig needs to leave the desk but wants to be alerted when to return
+- Any situation requiring a timed notification that must not be missed
+
+Examples:
+- "Set an alarm for 5pm to wrap up"
+- "Remind me in 30 minutes to check the build"
+- "Set a timer for 1 hour - time to take a break"
+
+* Approach: How We Work Together
+
+** Step 1: Craig Requests an Alarm
+
+Craig tells Claude when and why:
+- "Set a timer for 45 minutes - meeting starts"
+- "Alarm at 3:30pm to call the dentist"
+
+** Step 2: Claude Sets the Alarm
+
+Claude schedules the alarm using the =at= daemon with =notify=:
+
+#+begin_src bash
+echo "notify alarm 'Alarm' 'Time to call the dentist' --persist" | at 3:30pm
+echo "notify alarm 'Alarm' 'Meeting starts' --persist" | at now + 45 minutes
+#+end_src
+
+The =at= daemon:
+1. Schedules the notification (survives terminal close)
+2. Confirms the alarm was set with the scheduled time
+
+** Step 3: Alarm Fires
+
+When the scheduled time arrives:
+1. Distinctive sound plays (alarm.ogg)
+2. Dunst notification appears with:
+ - Alarm icon
+ - The custom message provided
+ - Normal urgency (not critical - doesn't imply emergency)
+ - No timeout (persists until dismissed)
+
+** Step 4: Craig Responds
+
+Craig dismisses the notification and acts on it.
+
+* Implementation
+
+** Setting Alarms
+
+Use the =at= daemon to schedule a =notify alarm= command:
+
+#+begin_src bash
+# Schedule alarm for specific time
+echo "notify alarm 'Alarm' 'Meeting starts' --persist" | at 3:30pm
+
+# Schedule alarm for relative time
+echo "notify alarm 'Alarm' 'Check the build' --persist" | at now + 30 minutes
+
+# Schedule alarm for tomorrow
+echo "notify alarm 'Alarm' 'Call the dentist' --persist" | at 3:30pm tomorrow
+#+end_src
+
+** Notification System
+
+Uses the =notify= command with the =alarm= type. The =notify= command provides 8 notification types with matching icons and sounds.
+
+#+begin_src bash
+# Immediate alarm notification (for testing)
+notify alarm "Alarm" "Your message here" --persist
+#+end_src
+
+The =--persist= flag keeps the notification on screen until manually dismissed.
+
+** Managing Alarms
+
+#+begin_src bash
+# List pending alarms
+atq
+
+# Cancel an alarm by job number
+atrm JOB_NUMBER
+#+end_src
+
+The =at= command accepts various time formats:
+- =now + 30 minutes= - relative time
+- =now + 1 hour= - relative time
+- =3:30pm= - specific time today
+- =3:30pm tomorrow= - specific time tomorrow
+- =noon= - 12:00pm today
+- =midnight= - 12:00am tonight
+* Principles to Follow
+
+** Reliability
+The alarm must fire. Use the =at= daemon which is designed for exactly this purpose and survives terminal closure and session changes.
+
+** Efficiency
+Simple invocation - Claude runs one command. No complex setup required per alarm.
+
+** Fail Audibly
+If the alarm fails to schedule, report the error clearly. Don't fail silently.
+
+** Testable
+The =notify alarm= command can be called directly to verify notifications work without waiting for a timer.
+
+** Non-Alarming
+Use normal urgency, not critical. The notification should be noticeable but not imply something has gone horribly wrong.
+
+* Limitations (Current Version)
+
+- *Does not survive logout/reboot* - Alarms scheduled via =at= are lost on logout/reboot
+- *No alarm management UI* - Use =atq= to list and =atrm= to remove alarms manually
+
+Future versions may add:
+- Reboot persistence via systemd timers or alarm state file
+
+* Living Document
+
+Update this workflow as we learn what works:
+- Sound choices that are distinctive but not jarring
+- Icon that clearly indicates alarm origin
+- Any edge cases discovered in use
+
+** Sound Resources
+
+For future notification sounds:
+- Local collection: =~/documents/sounds/= (various notification tones)
+- https://notificationsounds.com - good selection of clean notification tones
+- https://mixkit.co/free-sound-effects/notification/ - royalty-free sounds
+
+See =notify= package for the unified notification system used across all AI workflows.
+
diff --git a/docs/workflows/startup.org b/docs/workflows/startup.org
new file mode 100644
index 0000000..0241cfe
--- /dev/null
+++ b/docs/workflows/startup.org
@@ -0,0 +1,103 @@
+#+TITLE: Startup Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-02-07
+
+* Overview
+
+This workflow runs automatically at the beginning of EVERY session. It gives Claude project context, syncs templates, discovers available workflows, and determines session priorities. Do NOT ask Craig if he wants to run it — just execute it.
+
+* The Workflow
+
+** Step 1: Check for Interrupted Session
+
+- Run =date= for accurate timestamp
+- Check if =docs/session-context.org= exists — if so, previous session crashed. Read it immediately to recover context.
+- Check last session entry in notes.org for missing end timestamp — mention to Craig if found
+
+Rationale: Prevents losing work from crashed sessions.
+
+** Step 2: Sync Templates
+
+Template is authoritative — copy, don't diff:
+
+#+begin_src bash
+cp ~/projects/claude-templates/docs/protocols.org docs/protocols.org
+cp -r ~/projects/claude-templates/docs/workflows docs/
+cp -r ~/projects/claude-templates/docs/scripts docs/
+cp -r ~/projects/claude-templates/docs/announcements docs/
+#+end_src
+
+Mention significant updates if noticed (new workflows, protocol changes).
+
+Two workflow directories:
+- =docs/workflows/= — template (overwritten on sync, never edit in project)
+- =docs/project-workflows/= — project-specific (never touched by sync)
+
+Rationale: Keeps all projects aligned with latest protocols.
+
+** Step 3: Process Announcements
+
+- Check =docs/announcements/= for files (skip =the-purpose-of-this-directory.org=)
+- For each announcement: read, discuss with Craig, execute, report results, delete the announcement file
+
+Rationale: Announcements are one-off cross-project instructions from Craig.
+
+** Step 4: Scan Workflow Directories [CRITICAL STEP]
+
+List filenames from BOTH directories:
+
+#+begin_src bash
+ls -1 docs/workflows/
+ls -1 docs/project-workflows/ # if it exists
+#+end_src
+
+PRINT the filenames — these are the workflow lookup table.
+
+- Filenames are descriptive: "send-email.org" handles "send an email"
+- The word "workflow" in any user request → check these directories for a match
+- When a request matches a filename → read that file and execute its guidance
+- If no match → offer to create via create-workflow (goes to =project-workflows/=)
+
+Rationale: Workflow filenames are the discovery mechanism. The directory listing IS the catalog — no separate index needed.
+
+** Step 5: Read notes.org (Key Sections Only)
+
+protocols.org is already read before this workflow runs — skip it.
+
+Read these notes.org sections:
+1. Project-Specific Context (the key section)
+2. Active Reminders → surface to user immediately
+3. Pending Decisions → mention to user
+4. Last 2-3 Session History entries only
+
+Do NOT read: About This File, full history, archived sessions.
+
+Rationale: Gives Claude project context without wasting tokens on static content.
+
+** Step 6: Process Inbox
+
+- Check =./inbox/= directory
+- If empty: continue
+- If non-empty: process is MANDATORY (don't ask, just do it)
+- For each file: read, determine action, recommend filing, get approval, move
+
+Rationale: Ensures new documents don't go unnoticed.
+
+** Step 7: Ask About Priorities
+
+Ask: "Is there something urgent, or should we follow the what's-next workflow?"
+
+- If urgent: proceed immediately
+- If what's-next: check =docs/workflows/whats-next.org=
+- If unsure: surface reminders, pending decisions, recent work as context
+
+Rationale: Gives Craig control over session direction.
+
+* Common Mistakes
+
+1. Reading the entire notes.org file — only read key sections listed in Step 5
+2. Skipping template sync — miss important updates across projects
+3. Not checking for session-context.org — lose context from crashed sessions
+4. Forgetting to surface Active Reminders — Craig misses critical items
+5. Asking if Craig wants inbox processed — it's mandatory, not optional
+6. Announcing "session start complete" — just begin working on the chosen task
diff --git a/docs/workflows/status-check.org b/docs/workflows/status-check.org
new file mode 100644
index 0000000..efff16d
--- /dev/null
+++ b/docs/workflows/status-check.org
@@ -0,0 +1,178 @@
+#+TITLE: Status Check Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-02-02
+
+* Overview
+
+This workflow defines how Claude monitors and reports on long-running jobs (10+ minutes). It provides regular status updates, ETA estimates, and clear completion/failure signals with notifications.
+
+Uses the =notify= command for completion/failure notifications.
+
+* Problem We're Solving
+
+Long-running jobs create uncertainty:
+
+1. *Silent failures* - Jobs fail without notification, wasting time
+2. *Missed completions* - Job finishes but user doesn't notice for hours
+3. *No visibility* - User doesn't know if it's safe to context-switch
+4. *Unknown ETAs* - No sense of when to check back
+
+*Impact:* Delayed follow-up, wasted time, uncertainty about when to return attention to the task.
+
+* Exit Criteria
+
+The workflow is successful when:
+
+1. Claude proactively monitors long-running tasks (10+ minutes)
+2. Status updates arrive every 5 minutes with progress and ETA
+3. Completion/failure is clearly announced with notification
+4. Failures trigger investigation or confirmation before action
+
+* When to Use This Workflow
+
+Use automatically when:
+- Network transfers (rsync, scp, file sync)
+- Test suites expected to run long
+- Build processes
+- Any job estimated at 10+ minutes
+
+Use when Craig requests:
+- "Keep me posted on this"
+- "Provide status checks on this job"
+- "Let me know when it's done"
+- "Monitor this for me"
+
+* Approach: How We Work Together
+
+** Step 1: Initial Status
+
+When a long-running job starts, report:
+
+#+begin_example
+HH:MM - description - ETA
+19:10 - Starting file transfer of ~/videos to wolf - ETA ~30 minutes
+#+end_example
+
+Format: One line, under 120 characters.
+
+** Step 2: Progress Updates (Every 5 Minutes)
+
+Report progress with updated ETA:
+
+#+begin_example
+HH:MM - job description - update - ETA
+19:15 - File transfer to wolf - now transferring files starting with "h" - ETA ~25 minutes
+#+end_example
+
+If ETA changes significantly, explain why:
+
+#+begin_example
+19:20 - File transfer to wolf - network speed dramatically reduced - ETA ~40 minutes
+19:25 - File transfer to wolf - network speed recovered - ETA ~10 minutes
+#+end_example
+
+** Step 3: Completion
+
+On success:
+
+#+begin_example
+HH:MM - job description SUCCESS! - elapsed time
+19:35 - File transfer to wolf SUCCESS! - elapsed: ~25 minutes
+#+end_example
+
+Then:
+1. Play success sound and show persistent notification
+2. Report any relevant details (files transferred, tests passed, etc.)
+
+#+begin_src bash
+notify success "Job Complete" "File transfer to wolf finished" --persist
+#+end_src
+
+** Step 4: Failure
+
+On failure:
+
+#+begin_example
+HH:MM - job description FAILURE! - elapsed time
+Reason: Network connectivity dropped. Should I investigate, restart, or something else?
+#+end_example
+
+Then:
+1. Play failure sound and show persistent notification
+2. Investigate the reason OR ask for confirmation before diagnosing
+3. Unless fix is trivial and obvious, ask before fixing or rerunning
+
+#+begin_src bash
+notify fail "Job Failed" "File transfer to wolf - network error" --persist
+#+end_src
+
+* Status Format Reference
+
+| Situation | Format |
+|-----------+----------------------------------------------------------|
+| Initial | =HH:MM - description - ETA= |
+| Progress | =HH:MM - job description - update - ETA= |
+| Success | =HH:MM - job description SUCCESS! - elapsed time= |
+| Failure | =HH:MM - job description FAILURE! - elapsed time= + reason |
+
+All status lines should be under 120 characters.
+
+* Principles to Follow
+
+** Reliability
+Updates every 5 minutes, no exceptions. Status checks are never considered an interruption.
+
+** Transparency
+Honest progress reporting. If ETA changes, explain why. Don't silently adjust estimates.
+
+** ETA Honesty
+- Always try to estimate, even if uncertain
+- If truly unknown, say "ETA unknown"
+- When ETA changes significantly, explain the reason
+- A wrong estimate with explanation is better than no estimate
+
+** Fail Loudly
+Never let failures go unnoticed. Always announce failures with sound and persistent notification.
+
+** Ask Before Acting
+On failure, investigate or ask - don't automatically retry or fix unless the solution is trivial and obvious.
+
+* Implementation
+
+** Monitoring with Sleep (Blocking)
+
+To ensure 5-minute status updates happen reliably, use blocking sleep loops.
+Do NOT use =at= for reminders - it only notifies the user, not Claude.
+
+#+begin_src bash
+# Check status, sleep 5 min, repeat until job completes
+sleep 300 && date "+%H:%M" && tail -15 /path/to/output.log
+#+end_src
+
+This blocks the conversation but guarantees regular updates. The user has
+explicitly approved this approach - status checks are never an interruption.
+
+** Background Jobs
+
+For jobs run in background via Bash tool:
+1. Start job with =run_in_background: true=
+2. Note the output file path
+3. Use blocking sleep loop to check output every 5 minutes
+4. Continue until job completes or fails
+
+* Notification Reference
+
+#+begin_src bash
+# Success
+notify success "Job Complete" "description" --persist
+
+# Failure
+notify fail "Job Failed" "description" --persist
+#+end_src
+
+* Living Document
+
+Update as patterns emerge:
+- Which jobs benefit most from monitoring
+- ETA estimation techniques that work well
+- Common failure modes and responses
diff --git a/docs/workflows/sync-email.org b/docs/workflows/sync-email.org
new file mode 100644
index 0000000..52a7caf
--- /dev/null
+++ b/docs/workflows/sync-email.org
@@ -0,0 +1,108 @@
+#+TITLE: Sync Email Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-02-01
+
+* Overview
+
+This workflow syncs local maildir with remote email servers (Gmail and cmail/Proton) and updates the mu index for local searching.
+
+* Problem We're Solving
+
+Email lives on remote servers. To search or read emails locally, the local maildir needs to be updated from the servers. Without syncing, local tools (mu4e, mu find) only see stale data.
+
+* Exit Criteria
+
+Sync is complete when:
+1. mbsync finishes successfully (exit code 0)
+2. mu index completes successfully
+3. Sync summary is reported (new messages, any errors)
+
+* When to Use This Workflow
+
+When Craig says:
+- "sync email" or "sync mail"
+- "pull new mail"
+- "check for new email"
+- Before any workflow that needs to search or read local email
+
+* The Workflow
+
+** Step 1: Sync All Accounts
+
+Run mbsync to pull mail from all configured accounts:
+
+#+begin_src bash
+mbsync -a
+#+end_src
+
+This syncs both gmail and cmail accounts as configured in ~/.mbsyncrc.
+
+** Step 2: Index Mail
+
+Update the mu database to make new mail searchable:
+
+#+begin_src bash
+mu index
+#+end_src
+
+mu index is incremental by default - it only indexes new/changed messages.
+
+** Step 3: Report Results
+
+Report to Craig:
+- Number of new messages pulled (if visible in mbsync output)
+- Any errors encountered
+- Confirmation that sync and index completed
+
+** Handling Errors
+
+If errors occur, diagnose at that step. Common issues:
+
+*** UIDVALIDITY Errors
+
+UIDVALIDITY errors occur when UIDs change on the server (Proton Bridge resets) or when mu4e moves files without renaming them.
+
+*Prevention (mu4e users):* Add to Emacs config:
+#+begin_src elisp
+(setq mu4e-change-filenames-when-moving t)
+#+end_src
+
+*If errors occur:*
+1. First, try running mbsync again - [[https://isync.sourceforge.io/mbsync.html][official docs]] say it "will recover just fine if the change is unfounded"
+2. If errors persist, reset sync state (only if mail is safe on server):
+#+begin_src bash
+find ~/.mail/cmail -name ".uidvalidity" -delete
+find ~/.mail/cmail -name ".mbsyncstate" -delete
+mbsync cmail
+#+end_src
+
+*References:*
+- [[https://isync.sourceforge.io/mbsync.html][mbsync official documentation]]
+- [[https://pragmaticemacs.wordpress.com/2016/03/22/fixing-duplicate-uid-errors-when-using-mbsync-and-mu4e/][Fixing duplicate UID errors with mbsync and mu4e]]
+- [[https://www.julioloayzam.com/guides/recovering-from-a-mbsync-uidvalidity-change/][Recovering from mbsync UIDVALIDITY change]]
+
+*** Connection Errors
+- Gmail: Check network, may need app password refresh
+- cmail: Ensure Proton Bridge is running (check port 1143)
+
+#+begin_src bash
+ss -tlnp | grep 1143
+#+end_src
+
+* Mail Configuration Reference
+
+| Account | Local Path | IMAP Server |
+|---------+---------------+--------------------|
+| gmail | ~/.mail/gmail | imap.gmail.com |
+| cmail | ~/.mail/cmail | 127.0.0.1:1143 |
+
+* Principles
+
+- **Sync all accounts by default** - Unless Craig specifies a single account
+- **No pre-checks** - Don't verify connectivity before running; diagnose if errors occur
+- **Trust the tools** - mbsync and mu are robust; don't add unnecessary validation
+- **Never modify ~/.mail/ directly** - Read-only operations only; mbsync manages the maildir
+
+* Living Document
+
+Update this workflow as we discover new patterns or issues with email syncing.
diff --git a/docs/workflows/whats-next.org b/docs/workflows/whats-next.org
index f1870bf..701cce5 100644
--- a/docs/workflows/whats-next.org
+++ b/docs/workflows/whats-next.org
@@ -60,7 +60,7 @@ Follow this decision tree in order:
- **If user declines:** Continue to next step
*** 2. Check Active Reminders
-- Review Active Reminders section in project's NOTES.org (if it exists)
+- Review Active Reminders section in project's notes.org (if it exists)
- **If found:** Recommend reminder task
- **If user declines:** Ask for priority and add to todo.org, then continue
@@ -118,7 +118,7 @@ Reason: Blocks daily reading/annotation workflow
* Workflow Steps
1. **Scan in-progress tasks** - Check for incomplete work from previous session
-2. **Check reminders** - Review Active Reminders in NOTES.org
+2. **Check reminders** - Review Active Reminders in notes.org
3. **Scan for deadlines** - Look for time-sensitive tasks in todo.org
4. **Apply priority cascade** - Use V2MOM method order or simple priority ranking
5. **Make recommendation** - One task (or 2-3 if uncertain)
diff --git a/docs/workflows/session-wrap-up.org b/docs/workflows/wrap-it-up.org
index d7a8b35..1ab31ec 100644
--- a/docs/workflows/session-wrap-up.org
+++ b/docs/workflows/wrap-it-up.org
@@ -43,7 +43,7 @@ Without a structured wrap-up process, sessions can end poorly:
The wrap-up is complete when:
1. **Session history is documented:**
- - Key decisions made are recorded in NOTES.org
+ - Key decisions made are recorded in notes.org
- Work completed is summarized clearly
- Context needed for next session is captured
- Pending issues or blockers are noted
@@ -64,7 +64,7 @@ The wrap-up is complete when:
*Measurable validation:*
- =git status= shows "working tree clean"
- =git log -1= shows today's session commit
-- Session History section in NOTES.org has new entry
+- Session History section in notes.org has new entry
- User has clear sense of what was accomplished and what's next
* When to Use This Workflow
@@ -86,11 +86,11 @@ If this is the first time using this workflow in a project, perform this one-tim
** Clean Up Old "Wrap It Up" Documentation
-Before using this workflow, check if NOTES.org contains old/redundant "wrap it up" instructions:
+Before using this workflow, check if notes.org contains old/redundant "wrap it up" instructions:
-1. **Search NOTES.org for duplicate content:**
+1. **Search notes.org for duplicate content:**
#+begin_src bash
- grep -n "Wrap it up\|wrap-up" docs/NOTES.org
+ grep -n "Wrap it up\|wrap-up" docs/notes.org
#+end_src
2. **Look for sections that duplicate this workflow:**
@@ -101,14 +101,14 @@ Before using this workflow, check if NOTES.org contains old/redundant "wrap it u
3. **Replace with link to this workflow:**
- Remove the detailed inline instructions
- - Replace with: "See [[file:docs/workflows/session-wrap-up.org][Session Wrap-Up Workflow]]"
+ - Replace with: "See [[file:docs/workflows/wrap-it-up.org][Session Wrap-Up Workflow]]"
- Keep brief summary if helpful for quick reference
4. **Why this matters:**
- Prevents conflicting instructions (e.g., different git commit formats)
- Single source of truth for wrap-up process
- Easier to maintain and update in one place
- - Reduces NOTES.org file size
+ - Reduces notes.org file size
**Example cleanup:**
@@ -125,7 +125,7 @@ When Craig says this:
After:
#+begin_example
*** "Wrap it up"
-Execute the wrap-up workflow defined in [[file:docs/workflows/session-wrap-up.org][session-wrap-up.org]].
+Execute the wrap-up workflow defined in [[file:docs/workflows/wrap-it-up.org][wrap-it-up.org]].
Brief summary: Write session notes, git commit/push (no Claude attribution), valediction.
#+end_example
@@ -134,9 +134,9 @@ This cleanup should only be done ONCE when first adopting this workflow.
* The Workflow
-** Step 1: Write Session Notes to NOTES.org
+** Step 1: Write Session Notes to notes.org
-Add new entry to Session History section in =docs/NOTES.org=
+Add new entry to Session History section in =docs/notes.org=
*** Format for Session Entry
@@ -199,7 +199,7 @@ Brief summary of where things stand after this session.
*** Where to Add Session Notes
-- Open =docs/NOTES.org=
+- Open =docs/notes.org=
- Navigate to "Session History" section (near end of file)
- Add new =** Session:= entry at the TOP of the session list (most recent first)
- Use today's date in format: "Month Day, Year"
@@ -210,11 +210,11 @@ Brief summary of where things stand after this session.
After adding the current session notes, check if there are more than 5 sessions in the Session History section and move older ones to the archive:
1. **Count sessions in Session History:**
- - Count the number of =** Session:= entries in NOTES.org Session History section
+ - Count the number of =** Session:= entries in notes.org Session History section
- If 5 or fewer sessions exist, skip to next step (nothing to archive)
2. **Identify sessions to archive:**
- - Keep the 5 most recent sessions in NOTES.org
+ - Keep the 5 most recent sessions in notes.org
- Mark all older sessions (6th and beyond) for archiving
3. **Move old sessions to archive:**
@@ -226,11 +226,11 @@ After adding the current session notes, check if there are more than 5 sessions
* Archived Sessions
- This file contains archived session history from NOTES.org.
+ This file contains archived session history from notes.org.
Sessions are in reverse chronological order (most recent first).
#+end_example
- Open =docs/previous-session-history.org=
- - Cut old session entries from NOTES.org (6th session and beyond)
+ - Cut old session entries from notes.org (6th session and beyond)
- Paste them at the TOP of "Archived Sessions" section in previous-session-history.org
- Keep reverse chronological order (most recent archived session first)
- Save both files
@@ -239,7 +239,7 @@ After adding the current session notes, check if there are more than 5 sessions
- If there are 5 or fewer sessions in Session History
- Just continue to next step
-This keeps NOTES.org focused on recent work while preserving full history.
+This keeps notes.org focused on recent work while preserving full history.
** Step 2: Git Commit and Push
@@ -498,7 +498,7 @@ Do NOT:
Before considering wrap-up complete, verify:
-- [ ] Session History entry added to NOTES.org with today's date
+- [ ] Session History entry added to notes.org with today's date
- [ ] Work completed is clearly documented
- [ ] Context for next session is captured
- [ ] Key decisions and rationale are recorded