diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
| commit | c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch) | |
| tree | 3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates | |
| parent | 2b471da4bab014a2e096f63edc7aac235fc40fdd (diff) | |
| parent | 69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff) | |
| download | rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.tar.gz rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.zip | |
Merge commit '69c5e4ace81586c05dea6a9a3afd54dafa61a73b' as 'claude-templates'
Diffstat (limited to 'claude-templates')
88 files changed, 17181 insertions, 0 deletions
diff --git a/claude-templates/.ai/notes.org b/claude-templates/.ai/notes.org new file mode 100644 index 0000000..5f1860e --- /dev/null +++ b/claude-templates/.ai/notes.org @@ -0,0 +1,86 @@ +#+TITLE: Claude Code Notes - [Project Name] +#+AUTHOR: Craig Jennings & Claude +#+DATE: [Date] + +* About This File + +This file contains project-specific information for this project. + +**When to read this:** +- At the start of EVERY session (after reading protocols.org) +- When needing project context or history +- When checking reminders or pending decisions + +**What's in this file:** +- Project-specific context and goals +- Pending decisions +- Active reminders + +**Session history is NOT in this file.** Each session's record lives in =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= — one file per session. Catch-up reads the Summary sections of the most recent 5. + +**For protocols and conventions, see:** [[file:protocols.org][protocols.org]] + +* Project-Specific Context + +This is where context regarding this project is placed. + +Examples of what goes here: +- Project overview and goals +- People and their relationships to the user +- Contact information for companies and people +- Current state of the project +- Status report +- Links to important documents +- Technical architecture notes +- Key decisions and rationale + +** If this is the first session + +Run the first-session workflow: [[file:workflows/first-session.org][first-session.org]]. It walks through git/.ai policy, project orientation, and initializing this notes.org. Remove this heading once done. + +* PENDING DECISIONS + +This section tracks decisions that need Craig's input before work can proceed. + +**Instructions:** +- Add pending decisions as they arise during sessions +- Format: =** [Topic/Feature Name]= +- Include: What needs to be decided, options available, why it matters +- Remove decisions once resolved (the resolution is captured in the Session Log of the session where it was resolved) + +**Example format:** +#+begin_example +** Feature Name or Topic + +Craig needs to decide on [specific question]. + +Options: +1. Option A - [brief description, pros/cons] +2. Option B - [brief description, pros/cons] + +Why this matters: [impact on project] + +Implementation is ready - just need Craig's preference. +#+end_example + +** Current Pending Decisions + +(None currently - will be added as they arise) + +* Active Reminders + +** Current Reminders + +(None currently - will be added as needed) + +** Instructions for This Section + +When Craig says "remind me" about something: +1. Add it here with timestamp and description +2. If it's a TODO, also add to =/home/cjennings/sync/org/roam/inbox.org= scheduled for today +3. Check this section at start of every session +4. Remove reminders once addressed + +Format: +- =[YYYY-MM-DD]= Description of what to remind Craig about + diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org new file mode 100644 index 0000000..8bde31f --- /dev/null +++ b/claude-templates/.ai/protocols.org @@ -0,0 +1,538 @@ +#+TITLE: Claude Code Protocols +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2025-11-05 + +* About This File + +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 (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 (.ai/ 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 .ai/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) + +* Directory Architecture + +Every project using this template has an =.ai/= directory for Claude tooling and a project-level =docs/= directory for real project documentation. + +** =.ai/= (hidden, Claude tooling) + +Every file and directory has a defined purpose: + +| Item | Purpose | +|------+---------| +| =protocols.org= | Single entry point — behavioral instructions + directory map | +| =notes.org= | Project state: context, active reminders, pending decisions | +| =session-context.org= | Live session state (exists only during active sessions); renamed on wrap-up | +| =sessions/= | Archived session files (one per session) — =YYYY-MM-DD-HH-MM-description.org= | +| =workflows/= | Template workflows (synced from claude-templates, never edit in project) | +| =project-workflows/= | Project-specific workflows (never touched by sync) | +| =scripts/= | Template scripts | +| =someday-maybe.org= | Project ideas backlog | + +** =docs/= (visible, real project documentation) + +Reserved for actual project documentation — user-facing docs, architecture notes, setup guides, reference material. Not touched by the template sync. May not exist in every project. When Claude generates a project-specific reference doc (e.g., a setup guide, an architecture analysis), it goes here. + +* IMPORTANT - MUST DO + +** CRITICAL: Always Check the Time with =date= Command + +***NEVER GUESS THE TIME. ALWAYS RUN =date= TO CHECK.*** + +Claude's internal sense of time is unreliable. When mentioning what time it is - whether in conversation, session notes, timestamps, or scheduling - ALWAYS run: + +#+begin_src bash +date "+%A %Y-%m-%d %H:%M %Z" +#+end_src + +This applies to: +- "What time is it?" +- Session start/end timestamps +- Calculating time until appointments +- Setting alarms +- Any time-related statement + +Do NOT estimate, guess, or rely on memory. Just run the command. It takes one second and prevents errors. + +** Session Context File — Record + Recovery Anchor + +Location during session: =.ai/session-context.org= +Location after wrap-up: =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= + +This file serves two purposes with one mechanism: +1. *Crash recovery* — if the session dies mid-work, the live file is all that's left. On 2026-01-22 a session crashed during a 20-minute design discussion and all context was lost because this file wasn't being updated. +2. *Session archive* — at wrap-up the file is renamed into =.ai/sessions/=, becoming the permanent record. No transcription to notes.org; the file IS the record. + +*** File structure + +Two top-level sections: + +- =* Summary= — Structured distillation written at wrap-up. Subsections: Active Goal, Decisions, Data Collected / Findings, Files Modified, Next Steps. This is what's read at /catch up/ (first 5 most recent session files). +- =* Session Log= — Chronological narrative written /as you go/. Captures the sequence of events, rationale for decisions, dead ends tried, context that doesn't fit neatly into the Summary's sections. Use =** Topic= section headers at natural seams (major sub-tasks, phase changes, long pauses). Timestamps optional — use when genuinely useful. + +The Summary lets a future reader scan quickly. The Log preserves the /why/ and the sequence. + +*** When to update (Session Log) + +Append to the Session Log at /progress points/ — moments where something has changed that you'd want to recover to if the session crashed right now: + +- A decision was reached ("we agreed on X, not Y") +- A workflow phase completed, or a new phase is starting +- A substantive finding, diagnosis, or root cause identified +- Before starting a new subtask or changing direction +- Before context compaction (predictable event — save first) +- Before long-running or blocking tool calls — agent spawns, builds, rsync/ssh loops, MCP calls to external services. You could be waiting minutes; if anything crashes during the wait, everything since the last save is gone. +- Before destructive or irreversible operations — =rm -rf=, =git reset --hard=, force-push, package removal, database drops. If the op succeeds and something breaks afterward, you need to know what state you caused. + +Heuristic: /"If this session crashed right now, what would I wish I had written down?"/ If the answer is substantive, append to the Log. + +*** Safety net + +If 5 user turns pass without appending to the Log, save anyway. Semantic triggers should catch most cases; this fallback covers judgment failures. + +*** What counts as "updating" + +Actually write to the file using Edit or Write. Not "I should update it." Not "I'll update soon." Write it now. + +Prose form. Narrate the work: what changed, why, what was tried, what came next. Don't try to fit Session Log entries into the Summary's section template — that's for wrap-up. + +*** At session start + +Check if =.ai/session-context.org= exists: +- *Exists* → previous session was interrupted. Read it immediately to recover context. +- *Doesn't exist* → fresh session; create the file with a skeletal structure (title, =* Summary= with empty subsections, =* Session Log= header) when the first Log-worthy event occurs. + +*** After compaction + +Review =session-context.org= to confirm no essential context was lost, then continue. + +*** At session end (wrap-up) + +The wrap-it-up workflow handles this: +1. Write/refine the =* Summary= section by reading the =* Session Log= +2. Pick a 4-6 word description of the work (like a git-commit-message-series summary) +3. Rename =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= +4. Commit + push + +The file /persists/ under its new name. It is not deleted. The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. + +** 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. + +** Cross-Project Boundary — Stop and Ask + +If a request targets a file or task under a *different* project's =.ai/= scope than the current session (cwd is one project, the argument names another project's =todo.org=, =inbox/=, or similar), stop and ask before doing the work: "this looks like it belongs to <other project>'s session — do it from here, or switch projects?" Inline numbered options, no popup. + +Each project's =.ai/= is the scope boundary: =protocols.org=, =session-context.org=, =sessions/=, =notes.org=, =todo.org=, =inbox/=, plus the project's memory dir at =~/.claude/projects/<encoded-cwd>/memory/=. Crossing without flagging pollutes the current session's log with the other project's content and drops memories into the wrong dir. If the user confirms "do it from here," write a handoff file at =<other-project>/inbox/YYYY-MM-DD-handoff-from-<this-project>-<topic>.org= so the other project's next session picks it up. + +Canonical rule: =~/code/rulesets/claude-rules/cross-project.md=. + +* Important Terminology + +** "Let's run the [X] workflow" vs "I want to create an [X] workflow" + +These phrases mean DIFFERENT things! + +*** "Let's run/do the [workflow name] workflow" +This means: **Execute the existing workflow** for that process. + +*Example:* +- "Let's run the refactor workflow" -> Read .ai/workflows/refactor.org and guide through workflow +- "Let's do a refactor workflow" -> Same as above + +*** "I want to create an [X] workflow" +This means: **CREATE a new workflow definition** for doing X (meta-work). +This does **NOT** mean "let's DO X right now." + +*Example:* +- "I want to create a refactor workflow" -> Create .ai/workflows/refactor.org using create-workflow process + +When Craig uses this phrasing, trigger the create-workflow process from .ai/workflows/create-workflow.org. New workflows go to =.ai/project-workflows/= by default. Only put a workflow in =.ai/workflows/= (and =~/projects/claude-templates/.ai/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 +2. Git commit and push all changes +3. Valediction summary + +* User Information + +** Calendar Management + +Three ways to access Craig's calendars: Google Calendar MCP (preferred, both personal + work accounts), gcalcli (fallback, personal only), Emacs org files (read-only viewer). + +For tool recipes, authentication details, and credentials, see [[file:references/calendar-reference.org][calendar-reference.org]]. + + +** GPG Keys + +Craig has two GPG key pairs configured: + +| Email | Key ID | Purpose | +|-------+--------+---------| +| =c@cjennings.net= | =3388FB17E147A563558F2CEC0E56F0A5B832F070= | Default key | +| =craigmartinjennings@gmail.com= | =1A1F6932A25357793FB2B4C51C4D081632A5CDA7= | Gmail identity | + +Both are RSA 4096-bit, passphrase-protected, no expiry. + +*** Encrypting files +#+begin_src bash +gpg -e --default-recipient-self file.json # encrypt to Craig's default key +gpg -e -r craigmartinjennings@gmail.com file.json # encrypt to specific key +gpg -c file.json # symmetric (passphrase only, no key) +#+end_src + +*** Decrypting files +#+begin_src bash +gpg -d file.json.gpg > file.json # decrypt (pinentry GUI handles passphrase) +#+end_src + +*** Notes +- GPG pinentry uses a GUI dialog — works from Claude Code (no TTY needed) +- =--default-recipient-self= uses the =c@cjennings.net= key +- Prefer key-based encryption over symmetric for project secrets + +** Signature Image + +A transparent-background PNG scan of Craig's handwritten signature lives at +=~/pictures/cj-sig-transparent.png= (324×213, cropped), with a higher-resolution +version at =~/pictures/cj-sig-no-background.png= (1536×1024). Both are symlinks +into the archsetup stow dotfiles (=~/code/archsetup/dotfiles/common/pictures/=), +so they're present on every machine. RGBA, transparent background; composites +cleanly over a form line. + +Default for signing documents is still Craig signing by hand in Xournal++. Only +overlay this image onto a PDF (or other document) when Craig *explicitly asks +for it on a specific document* — never sign for him on your own initiative. The +=edit-pdf= workflow (project-level, where present) has the reportlab snippet for +stamping it. + +** Task List Location +Craig's global task list is available at: =/home/cjennings/sync/org/roam/inbox.org= + +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. + +** Working Style + +*** General Preferences +- Prefers detailed preparation before high-stakes meetings +- Values practice/role-play for negotiations and general learning +- Makes decisions based on principles and timeline arguments +- Prefers written documentation over verbal agreements + +*** Emacs as a Primary Working Tool +- Craig uses Emacs as his primary tool (most everything Craig does is inside Emacs) +- Consider Emacs packages along with other software when recommending software solutions +- Look for ways to streamline routine work with Emacs custom code if no packages exist + +*** Wayland Environment (No XWayland) +Craig runs a pure Wayland setup (Hyprland) and avoids XWayland/Xorg apps. + +- Clipboard: Use =wl-copy= and =wl-paste= (NOT =xclip= or =xsel=) +- Window management: Use Hyprland commands (NOT =xkill=, =xdotool=, etc.) +- Prefer Wayland-native tools over X11 equivalents +- Open URLs in browser: Use =google-chrome-stable "URL" &>/dev/null &= + - The =&>/dev/null &= is required to detach the process and suppress output + - Without it, the command may appear to hang or produce no result + +*** Shell aliases (=ls= → =exa=) +Craig's shell aliases =ls= to =exa=, which prints nothing to non-TTY pipes (e.g. when capturing =ls= output in a Bash tool call). The result looks like the directory is empty when it isn't. + +- Always use =\ls= (or =command ls=) when capturing output. The backslash bypasses the alias. +- Applies to =ls -la=, =ls -t=, glob expansions piped through =ls=, and any =ls= invocation whose output gets read programmatically. +- Symptom if forgotten: the Bash tool returns empty output and you mistakenly conclude the directory is empty. + +** Miscellaneous Information +- Craig currently lives in New Orleans, LA +- Craig's phone number: 510-316-9357 +- 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 + +** Setting Alarms / 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. + +*** Setting an alarm +#+begin_src bash +echo 'notify alarm "Title" "Message"' | at 10:55am +#+end_src + +*** Examples +#+begin_example +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 + +* Session Protocols + +** CRITICAL: Git Commit Requirements + +***IMPORTANT: ALL commits must be made as Craig, NOT as Claude.*** + +***CRITICAL: NO Claude Code or Anthropic attribution ANYWHERE in commits.*** + +When creating commits: + +1. **Author Identity**: NEVER commit as Claude. All commits must use Craig's identity. + - Git will use the configured user.name and user.email + - Do NOT modify git config + - **ABSOLUTELY NO** Co-Authored-By lines + - **ABSOLUTELY NO** "Generated with Claude Code" text + - **ABSOLUTELY NO** Anthropic attribution of any kind + - Write commits AS CRAIG, not as Claude Code + +2. **Commit Message Format**: + - Use project-specific commit format if defined + - Otherwise: concise subject line and terse description only + - **ONLY subject line and terse description - NO Claude Code attribution** + - Keep messages clear and informative + +3. **No Claude-tooling artifacts**: Commit messages describe project changes only — the meta-process of how work got shipped stays out of public git history. + - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/sessions/=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ...") + - Subject lines must NEVER start with =session:= as a conventional-commit type — use =docs:=, =refactor:=, =fix:=, =feat:=, =chore:=, etc. (real change categories) + - When a wrap-up commit bundles many changes from a session, describe what /shipped/ (e.g., =refactor: extract RAID logic + add bats testing infrastructure=), not that a session happened + - Same spirit as the no-Claude-attribution rule: the tooling stays invisible in =git log= + +4. **Validation**: + - Claude should validate commit message format before committing + - Ensure no AI attribution or tooling-artifact references appear anywhere in commit + +** IMPORTANT: Reminders Protocol + +When starting a new session: +- 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 +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. + +** Workflows: "Let's run/do the [workflow name] workflow" + +When Craig says this phrase: + +1. **Check =.ai/workflows/= for match** + - If exact match found: Read and guide through process + - Example: "refactor workflow" -> read .ai/workflows/refactor.org + +2. **Check =.ai/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'?" + +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 — new workflows go to =.ai/project-workflows/= by default + +** Long-Running Process Status Updates + +When monitoring a long-running process (rsync, large downloads, builds, VM tests, etc.), follow this protocol: + +***At Start:*** +1. Run =date= to get accurate time +2. Announce the task/job beginning +3. Provide best-guess ETA for completion + +#+begin_example +**14:30** - Starting ISO build. ETA: ~10 minutes. +#+end_example + +***Every 5 Minutes:*** +- Check progress and display status in format: =HH:MM= - terse description - ETA + +#+begin_example +**14:35** - ISO build: packages installed, creating squashfs. ETA: ~5 min. +**14:40** - ISO build: squashfs 95% complete. ETA: ~1 min. +#+end_example + +***At Completion:*** +1. Send notification via notify script: + #+begin_src bash + notify success "Task Complete" "Description of what finished" + #+end_src + Use =fail= type instead of =success= if the task failed. +2. Provide summary of success or failure + +#+begin_example +**14:42** - ISO build complete. Size: 2.0G. Ready for testing. +#+end_example + +***Guidelines:*** +- Always run =date= for accurate timestamps +- Keep progress descriptions terse but informative +- Update ETA as job progresses +- If ETA cannot be determined, say "ETA unknown" rather than guessing wildly + +***Why This Matters:*** +- Craig may be working on other things while waiting +- Status updates provide confidence the process is still running +- ETAs help with planning (e.g., "I have time for coffee" vs "stay close") +- Sound notification alerts Craig when he's away from the screen +- If something stalls, the updates make it obvious + +** "Wrap it up" / "That's a wrap" / "Let's call it a wrap" + +When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps: + +1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log) +2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= +3. *Git commit + push* to all remotes (see Git Commit Requirements) +4. *Valediction* — brief, warm, specific closing + +The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted. + +** Where to put generated documents + +Claude needs to add information to =.ai/notes.org=. For large amounts of information: + +- **Claude tooling context** (session state, workflows, internal research that only Claude needs) → break out into a separate file in =.ai/=, link it from =.ai/notes.org= +- **Real project documentation** (setup guides, reference material, architecture notes — anything Craig might point other people at) → place in =docs/= at project root, not =.ai/=. Link from =.ai/notes.org= if Claude needs to reference it. + +**Project-specific decision:** Should =.ai/= be committed to git or added to =.gitignore=? +- Ask Craig on first session if not specified +- Code projects (Emacs packages, libraries) usually gitignore =.ai/= — session notes are private, not part of the codebase +- Personal/documentation projects usually commit =.ai/= — the project history IS the project +- =docs/= (when it exists) is typically tracked either way — it's real documentation + +**When to break out documents:** +- If notes.org's Project-Specific Context section gets very large (> 1500 lines) — split into focused reference docs +- Session records go in =.ai/sessions/= automatically (one file per session via wrap-up workflow) — never mixed into notes.org + +* File Format Preferences + +** ALWAYS Use Org-Mode Format + +Craig uses Emacs as primary tool. **ALWAYS** create new documentation files in =.org= format, not =.md= (markdown). + +*Rationale:* +- Org-mode files are well-supported in Emacs +- Can be easily exported to any other format (HTML, PDF, Markdown, etc.) +- Better integration with user's workflow + +*Exception:* Only use .md if specifically requested or if file is intended for GitHub/web display where markdown is expected. + +** NEVER Use Spaces in Filenames + +**ALWAYS** use hyphens (=-=) to separate words in filenames. Underscores (=_=) are also acceptable. + +*Rationale:* +- Spaces cause problems with links across different operating systems +- User works with Mac, Windows, Linux, and potentially other systems +- Hyphens create more reliable, portable filenames +- Easier to work with in command-line tools + +*Examples:* +- Good: =project-meeting-notes.org= +- Good: =change-log-2025-11-04.md= +- Bad: =project meeting notes.org= +- Bad: =change log 2025.11.04.md= + +* File Naming Conventions + +** Files Too Large to Read + +PDFs or other files that are too large for Claude to read should be prefixed with =TOOLARGE-= to prevent read errors that halt the session. + +Example: +- Original: =assets/large-architectural-plans.pdf= +- Renamed: =assets/TOOLARGE-large-architectural-plans.pdf= + +** Unreadable Binary Files (.docx Format) + +Binary .docx files cannot be read directly by Claude. When encountering these: +- Convert to markdown format using pandoc: =pandoc file.docx -o file.md= +- Keep the original .docx file for reference +- Work with the converted .md file for analysis and editing + +** CRITICAL: Always Keep Links Current + +Many documents are linked in org files using org-mode =file:= links. Craig relies on these links being valid at all times. + +**MANDATORY WORKFLOW - When renaming or moving ANY file:** + +1. **BEFORE renaming:** Search ALL org files for references to that file + - Use grep or search tools to find both filename and partial matches + - Check in TODO items and event log sections + +2. **Rename or move the file** + +3. **IMMEDIATELY AFTER:** Update ALL =file:= links to new path/filename + - Update links in task files + - Update links in event logs + - Update links in reference sections + +4. **Verify:** Test a few updated links to ensure they point to valid files + +Example workflow: +#+begin_example +# Step 1: Search before renaming +grep -rn "2025-10-15-invoice.pdf" *.org + +# Step 2: Rename the file +mv documents/2025-10-15-invoice.pdf documents/2025-10-15-vendor-invoice.pdf + +# Step 3: Update all references in affected .org files +# Edit to change: +# file:documents/2025-10-15-invoice.pdf +# to: +# file:documents/2025-10-15-vendor-invoice.pdf + +# Step 4: Verify links work +#+end_example + +*Why This is Critical:* +- Org files are primary task tracking and reference system +- Event logs document complete history with file references +- Craig depends on clicking links to access documents quickly +- Broken links disrupt workflow and make documentation unreliable + +**NEVER rename or move files without updating links in the same session.** + +* Session Start - AUTOMATIC + +At the start of EVERY session, run [[file:workflows/startup.org][.ai/workflows/startup.org]]. Do NOT ask — just do it automatically. diff --git a/claude-templates/.ai/references/calendar-reference.org b/claude-templates/.ai/references/calendar-reference.org new file mode 100644 index 0000000..b44c0f1 --- /dev/null +++ b/claude-templates/.ai/references/calendar-reference.org @@ -0,0 +1,66 @@ +#+TITLE: Calendar Reference +#+AUTHOR: Craig Jennings & Claude + +Tool recipes, authentication, and credentials for Craig's calendar +setup. Three access methods, in order of preference. + +* Google Calendar MCP Server (preferred for all calendar operations) + +Craig has the =@cocal/google-calendar-mcp= MCP server configured at user scope (=~/.claude.json=). It provides full read/write access to Google Calendar via MCP tools. + +Two accounts are authenticated: +- *personal* — craigmartinjennings@gmail.com (primary: "Craig Google") +- *work* — craig.jennings@deepsat.com (primary: "Craig Deepsat") + +MCP tools available: +- =list-events=, =search-events=, =get-event= — read events +- =create-event=, =create-events= — add events +- =update-event= — modify events +- =delete-event= — remove events +- =list-calendars=, =list-colors= — calendar metadata +- =get-freebusy= — check availability +- =manage-accounts= — add/remove/list authenticated accounts +- =respond-to-event= — accept/decline invitations +- =get-current-time= — current time in any timezone + +Use =account_id: "personal"= or =account_id: "work"= to specify which account. + +Default calendar for adding events: "Craig Google" (personal account). + +Calendar workflows are available alongside this reference: add-calendar-event, edit-calendar-event, delete-calendar-event, read-calendar-events. + +If re-authentication is needed: +- Use the =manage-accounts= MCP tool with =action: "add"= and the account nickname +- OAuth credentials: =~/projects/homelab/assets/gcp-oauth.keys.json= +- Google Cloud app is in production mode (tokens don't expire after 7 days) +- See =~/projects/homelab/.ai/gcalcli-setup.org= for Google Cloud project details + +* gcalcli (fallback for personal account only) + +Craig has =gcalcli= installed via pipx, authenticated to his personal Google account only. + +#+begin_src bash +gcalcli agenda # upcoming events +gcalcli calw # weekly view +gcalcli add --title "..." --when "..." --duration "60" # add event +gcalcli search "..." # search events +gcalcli delete "..." # delete event +#+end_src + +Use =--calendar "Craig Google"= when adding events. + +gcalcli does NOT have access to the work (DeepSat) calendar. Use the MCP server for work calendar operations. + +If gcalcli needs re-authentication, credentials are stored in the homelab project: =~/projects/homelab/assets/gcalcli-client-secret.json.gpg= (GPG encrypted). + +* Emacs org files (read-only, for viewing schedules) + +Craig's calendars are at: =~/.emacs.d/data/*cal.org= (gcal.org, dcal.org, pcal.org) + +These files are **READ-ONLY** — NEVER add anything to them. + +Use this to: +- Check meeting times and schedules +- Verify when events occurred +- See what's upcoming +- Note: only updated periodically when Emacs is running — may be stale diff --git a/claude-templates/.ai/retrospectives/PRINCIPLES.org b/claude-templates/.ai/retrospectives/PRINCIPLES.org new file mode 100644 index 0000000..f5b81dd --- /dev/null +++ b/claude-templates/.ai/retrospectives/PRINCIPLES.org @@ -0,0 +1,51 @@ +#+TITLE: Working Principles +#+DESCRIPTION: Behavioral lessons learned from retrospectives. Read at session start. + +* How We Work Together + +** Sync Before Action +- Confirm before destructive or irreversible actions +- State what I'm about to do and wait for go-ahead +- "Wait, wait, wait" is valid and important feedback +- Don't assume the next step - ask or confirm + +** Verify Assumptions +- When something "should work" but doesn't, question the assumption +- Test one variable at a time to isolate causes +- Don't stack fixes - apply one, test, then apply next + +** Clean Up After Yourself +- Reset temporary changes before finishing +- Verify system is in expected state before moving on +- Don't leave debug flags, temp files, or test configurations + +** Research Before Guessing +- Check community forums, release notes, known issues +- External sources often have answers +- The obvious fix isn't always the right fix + +** Patience Over Speed +- Taking time to sync improves effectiveness +- Rushing creates mistakes that cost more time +- Working together > working fast + +* Checklists + +** (Add project-specific checklists here) + +* Revision History + +| Date | Change | +|------+--------| +| | | + +* Notes + +This is a template. Copy to your project's .ai/ directory and customize. + +Add principles learned from retrospectives. Keep them: +- Short and actionable +- Focused on behavior, not technical facts +- Easy to remember and apply + +Review at session start, especially after updates. diff --git a/claude-templates/.ai/scripts/cj-remove-block.py b/claude-templates/.ai/scripts/cj-remove-block.py new file mode 100644 index 0000000..71c7b3d --- /dev/null +++ b/claude-templates/.ai/scripts/cj-remove-block.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""cj-remove-block — Remove a cj annotation block from an org file by line range. + +Idempotently deletes lines [start, end] (1-indexed, inclusive) from the file, +but only after validating that those lines actually look like a cj annotation +(either a `#+begin_src cj: ... #+end_src` fence pair or a single `cj:` line). +The validation step is the point — it protects against accidentally trimming +the wrong block when line numbers drift between a `cj-scan` call and a remove call. + +Usage: + cj-remove-block --file FILE.org --start N --end M + +Companion to the /respond-to-cj-comments skill and to cj-scan.py. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE) +SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE) +LEGACY_CJ_RE = re.compile(r"^\s*cj:\s") + + +def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, str]: + """Return (ok, reason). Validates start..end (1-indexed, inclusive) is a cj range.""" + if end < start: + return False, f"Range end ({end}) is before start ({start})" + if start < 1 or end > len(lines): + return False, ( + f"Range {start}..{end} is out of bounds for a file with {len(lines)} lines" + ) + + first = lines[start - 1] + last = lines[end - 1] + + if start == end: + # Single-line removal must look like legacy inline. + if LEGACY_CJ_RE.match(first): + return True, "" + return False, ( + f"Line {start} does not look like a legacy inline cj: line " + f"(got: {first[:60]!r})" + ) + + # Multi-line removal must look like a source-block fence pair. + if not SRC_OPEN_RE.match(first): + return False, ( + f"Line {start} does not look like a #+begin_src cj: opening fence " + f"(got: {first[:60]!r})" + ) + if not SRC_CLOSE_RE.match(last): + return False, ( + f"Line {end} does not look like a #+end_src closing fence " + f"(got: {last[:60]!r})" + ) + return True, "" + + +def remove_range(path: Path, start: int, end: int) -> None: + """Read path, validate range looks like cj content, remove the range, write back.""" + text = path.read_text() + had_trailing_newline = text.endswith("\n") + lines = text.splitlines(keepends=False) + + ok, reason = looks_like_cj_range(lines, start, end) + if not ok: + print(f"cj-remove-block: refusing to remove — {reason}", file=sys.stderr) + sys.exit(1) + + new_lines = lines[: start - 1] + lines[end:] + new_text = "\n".join(new_lines) + if new_lines and had_trailing_newline: + new_text += "\n" + elif not new_lines and had_trailing_newline: + new_text = "" + path.write_text(new_text) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Remove a cj annotation block from an org file by line range.", + ) + parser.add_argument("--file", required=True, type=Path, help="Path to the org file.") + parser.add_argument("--start", required=True, type=int, help="Start line (1-indexed, inclusive).") + parser.add_argument("--end", required=True, type=int, help="End line (1-indexed, inclusive).") + args = parser.parse_args() + + if not args.file.is_file(): + print(f"Not a file: {args.file}", file=sys.stderr) + return 2 + + remove_range(args.file, args.start, args.end) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cj-scan.py b/claude-templates/.ai/scripts/cj-scan.py new file mode 100644 index 0000000..54e2bf9 --- /dev/null +++ b/claude-templates/.ai/scripts/cj-scan.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""cj-scan — Parse an org file for cj annotations and VERIFY-placement audit. + +Output: JSON to stdout with three top-level keys: +- cj_blocks: every cj annotation found (source-block or legacy-inline form) +- verify_tasks: every VERIFY heading with placement validity + suggested promotion target +- unclosed_blocks: any source-block fence that opened but never closed + +Usage: + cj-scan FILE.org + +Companion to the /respond-to-cj-comments skill — the skill calls this script +to get a single structured view of every cj annotation and every VERIFY +placement violation in a single tool call, instead of stitching the picture +together from multiple grep + Read round-trips. +""" + +from __future__ import annotations + +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +# VERIFY placement: top-level under a `*` section, or first-level child of a +# `**` parent task. Anything else gets a promotion_target suggestion. +VALID_VERIFY_DEPTHS = {2, 3} + +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:\s*(\S*)\s*$", re.IGNORECASE) +SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE) +LEGACY_CJ_RE = re.compile(r"^\s*cj:\s*(.*)$") +VERIFY_KEYWORD_RE = re.compile(r"^VERIFY(\s|\[|$)") + + +@dataclass +class HeadingFrame: + depth: int + heading: str + + +def promotion_target(depth: int) -> int | None: + """Return the suggested target depth for a misplaced VERIFY, or None if valid.""" + if depth in VALID_VERIFY_DEPTHS: + return None + if depth < 2: + return 2 + return 3 + + +def is_verify_heading(heading_text: str) -> bool: + """True when heading text begins with the VERIFY keyword (optional priority cookie).""" + return bool(VERIFY_KEYWORD_RE.match(heading_text)) + + +def scan_file(path: Path) -> dict[str, object]: + """Scan an org file and return cj_blocks + verify_tasks + unclosed_blocks.""" + cj_blocks: list[dict[str, object]] = [] + verify_tasks: list[dict[str, object]] = [] + unclosed_blocks: list[dict[str, object]] = [] + heading_stack: list[HeadingFrame] = [] + + in_cj_block = False + block_start_line: int | None = None + block_label: str | None = None + block_body: list[str] = [] + + file_str = str(path) + lines = path.read_text().splitlines() + + for lineno, line in enumerate(lines, start=1): + if in_cj_block: + if SRC_CLOSE_RE.match(line): + cj_blocks.append({ + "file": file_str, + "form": "source-block", + "start_line": block_start_line, + "end_line": lineno, + "body": "\n".join(block_body), + "label": block_label, + "parent_heading_chain": [asdict(h) for h in heading_stack], + "parent_depth": heading_stack[-1].depth if heading_stack else 0, + }) + in_cj_block = False + block_start_line = None + block_label = None + block_body = [] + else: + block_body.append(line) + continue + + m_heading = HEADING_RE.match(line) + if m_heading: + depth = len(m_heading.group(1)) + heading_text = m_heading.group(2).strip() + # Pop frames at this depth or deeper before pushing the new one. + while heading_stack and heading_stack[-1].depth >= depth: + heading_stack.pop() + heading_stack.append(HeadingFrame(depth=depth, heading=heading_text)) + if is_verify_heading(heading_text): + pt = promotion_target(depth) + verify_tasks.append({ + "file": file_str, + "line": lineno, + "depth": depth, + "heading": heading_text, + "valid_depth": pt is None, + "promotion_target": pt, + }) + continue + + m_src_open = SRC_OPEN_RE.match(line) + if m_src_open: + in_cj_block = True + block_start_line = lineno + block_label = m_src_open.group(1) or None + block_body = [] + continue + + m_legacy = LEGACY_CJ_RE.match(line) + if m_legacy: + cj_blocks.append({ + "file": file_str, + "form": "legacy-inline", + "start_line": lineno, + "end_line": lineno, + "body": m_legacy.group(1).strip(), + "parent_heading_chain": [asdict(h) for h in heading_stack], + "parent_depth": heading_stack[-1].depth if heading_stack else 0, + }) + + if in_cj_block: + unclosed_blocks.append({ + "file": file_str, + "start_line": block_start_line, + "label": block_label, + }) + + return { + "cj_blocks": cj_blocks, + "verify_tasks": verify_tasks, + "unclosed_blocks": unclosed_blocks, + } + + +def main() -> int: + if len(sys.argv) != 2: + print("Usage: cj-scan FILE.org", file=sys.stderr) + return 2 + path = Path(sys.argv[1]) + if not path.is_file(): + print(f"Not a file: {path}", file=sys.stderr) + return 2 + result = scan_file(path) + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cmail-action.py b/claude-templates/.ai/scripts/cmail-action.py new file mode 100755 index 0000000..10eb215 --- /dev/null +++ b/claude-templates/.ai/scripts/cmail-action.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +""" +cmail-action — IMAP triage operations against Proton Mail Bridge. + +Mirrors the operations the Gmail MCP server provides for gmail/dmail +(list-unread, read, mark-read, star, unstar, trash) so the +process-unread-emails workflow can drive cmail end-to-end the same way. + +Connects to local Proton Bridge IMAP at 127.0.0.1:1143 with STARTTLS, +using the Bridge-generated app password at ~/.config/.cmailpass and the +Bridge self-signed certificate at ~/.config/protonbridge.pem. Cert CN +is 127.0.0.1 but lacks a SubjectAltName, so hostname verification is +disabled (connection is to localhost — verifying via the pinned cert +is sufficient). + +IMAP -> Proton mapping: +- \\Seen flag -> Read state +- \\Flagged flag -> Starred label +- MOVE to Trash -> Trash folder +- COPY to label -> applies the label (Starred etc.) +""" + +import argparse +import email +import imaplib +import json +import mimetypes +import smtplib +import ssl +import sys +from email.message import EmailMessage +from email.policy import default as default_policy +from pathlib import Path + +HOST = "127.0.0.1" +PORT = 1143 +SMTP_PORT = 1025 +USER = "c@cjennings.net" +PASS_FILE = Path.home() / ".config" / ".cmailpass" +CERT_FILE = Path.home() / ".config" / "protonbridge.pem" + +INBOX = "INBOX" +TRASH = "Trash" + + +def connect(): + if not PASS_FILE.is_file(): + sys.exit(f"error: missing password file {PASS_FILE}") + if not CERT_FILE.is_file(): + sys.exit(f"error: missing bridge cert {CERT_FILE}") + ctx = ssl.create_default_context(cafile=str(CERT_FILE)) + ctx.check_hostname = False + try: + M = imaplib.IMAP4(HOST, PORT) + except OSError as e: + sys.exit(f"error: cannot reach Bridge at {HOST}:{PORT} ({e}). " + f"Is protonmail-bridge running? " + f"(systemctl --user status protonmail-bridge)") + M.starttls(ssl_context=ctx) + password = PASS_FILE.read_text().strip() + try: + M.login(USER, password) + except imaplib.IMAP4.error as e: + sys.exit(f"error: IMAP login failed for {USER}: {e}") + return M + + +def _select(M, mailbox=INBOX, readonly=False): + typ, data = M.select(mailbox, readonly=readonly) + if typ != "OK": + sys.exit(f"error: cannot select {mailbox}: {data}") + + +def _decode_header(value): + if value is None: + return "" + return str(value) + + +def parse_fetch_metadata(meta): + """Parse FLAGS and RFC822.SIZE out of an IMAP FETCH metadata string. + + Returns {"flags": str, "size": int | None}. Tolerates malformed input + (returns the defaults rather than raising). + """ + result = {"flags": "", "size": None} + flags_idx = meta.find("FLAGS (") + if flags_idx != -1: + end = meta.find(")", flags_idx) + if end != -1: + result["flags"] = meta[flags_idx + 7:end] + # Tokenize with parens stripped so RFC822.SIZE matches whether or not + # it abuts an opening paren in the raw response (e.g. "(RFC822.SIZE 500)" + # would otherwise tokenize as "(RFC822.SIZE" and miss the equality check). + tokens = meta.replace("(", " ").replace(")", " ").split() + for i, p in enumerate(tokens): + if p == "RFC822.SIZE" and i + 1 < len(tokens): + try: + result["size"] = int(tokens[i + 1]) + except ValueError: + pass + break + return result + + +def extract_body(msg): + """Pick a printable body out of an email.message.EmailMessage. + + Multipart: text/plain preferred, text/html fallback. Single-part: + returns content directly. Returns None if no body found. + """ + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + return part.get_content() + for part in msg.walk(): + if part.get_content_type() == "text/html": + return part.get_content() + return None + return msg.get_content() + + +def build_message(from_addr, to_addr, subject, body, attachments=None): + """Construct an EmailMessage from the given fields and attachments. + + attachments is a list of (filename, maintype, subtype, content_bytes) + tuples — typically the return value of load_attachment per file. Pure + function: no I/O, no SMTP. + """ + msg = EmailMessage() + msg["From"] = from_addr + msg["To"] = to_addr + msg["Subject"] = subject + msg.set_content(body) + for filename, maintype, subtype, content in (attachments or []): + msg.add_attachment(content, maintype=maintype, subtype=subtype, + filename=filename) + return msg + + +def load_attachment(path): + """Read a file and return (filename, maintype, subtype, content_bytes). + + MIME type comes from mimetypes.guess_type; falls back to + application/octet-stream when guess returns None. Raises FileNotFoundError + for missing paths and IsADirectoryError for directories. + """ + if not path.exists(): + raise FileNotFoundError(f"attachment not found: {path}") + if path.is_dir(): + raise IsADirectoryError(f"attachment path is a directory: {path}") + mime, _ = mimetypes.guess_type(path.name) + if mime is None: + maintype, subtype = "application", "octet-stream" + else: + maintype, subtype = mime.split("/", 1) + return (path.name, maintype, subtype, path.read_bytes()) + + +def smtp_connect(): + """Connect to Proton Bridge's local SMTP submission endpoint. + + Mirrors connect()'s pattern: STARTTLS against the pinned cert, + plaintext password from PASS_FILE. Skipped from unit tests for + the same reason connect() is — network + SSL + file I/O. + """ + if not PASS_FILE.is_file(): + sys.exit(f"error: missing password file {PASS_FILE}") + if not CERT_FILE.is_file(): + sys.exit(f"error: missing bridge cert {CERT_FILE}") + ctx = ssl.create_default_context(cafile=str(CERT_FILE)) + ctx.check_hostname = False + try: + smtp = smtplib.SMTP(HOST, SMTP_PORT) + except OSError as e: + sys.exit(f"error: cannot reach Bridge SMTP at {HOST}:{SMTP_PORT} ({e}). " + f"Is protonmail-bridge running?") + smtp.starttls(context=ctx) + password = PASS_FILE.read_text().strip() + try: + smtp.login(USER, password) + except smtplib.SMTPException as e: + sys.exit(f"error: SMTP login failed for {USER}: {e}") + return smtp + + +def cmd_list_unread(args): + M = connect() + try: + _select(M, INBOX, readonly=True) + typ, data = M.uid("SEARCH", None, "UNSEEN") + if typ != "OK": + sys.exit(f"error: search failed: {data}") + uids = data[0].split() if data and data[0] else [] + if args.limit and len(uids) > args.limit: + uids = uids[-args.limit:] + out = [] + for uid in uids: + uid_s = uid.decode() + typ, data = M.uid( + "FETCH", uid, + "(BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)] " + "FLAGS RFC822.SIZE)" + ) + if typ != "OK" or not data or not data[0]: + continue + # FLAGS / RFC822.SIZE may arrive in a non-tuple chunk after + # the BODY literal closes. Concatenate all chunks before + # parsing so the parser sees the full metadata. + hdr_raw = b"" + meta_str = "" + for chunk in data: + if isinstance(chunk, tuple): + hdr_raw = chunk[1] + meta_str += chunk[0].decode("utf-8", errors="replace") + " " + elif isinstance(chunk, (bytes, bytearray)): + meta_str += chunk.decode("utf-8", errors="replace") + " " + parsed = parse_fetch_metadata(meta_str) + msg = email.message_from_bytes(hdr_raw, policy=default_policy) + out.append({ + "uid": uid_s, + "from": _decode_header(msg.get("From")), + "to": _decode_header(msg.get("To")), + "subject": _decode_header(msg.get("Subject")), + "date": _decode_header(msg.get("Date")), + "flags": parsed["flags"], + "size": parsed["size"], + }) + print(json.dumps(out, indent=2, ensure_ascii=False)) + finally: + M.logout() + + +def cmd_read(args): + M = connect() + try: + _select(M, INBOX, readonly=True) + typ, data = M.uid("FETCH", str(args.uid).encode(), "(RFC822)") + if typ != "OK" or not data or not data[0]: + sys.exit(f"error: uid {args.uid} not found in {INBOX}") + raw = data[0][1] + msg = email.message_from_bytes(raw, policy=default_policy) + for h in ("From", "To", "Cc", "Date", "Subject"): + v = msg.get(h) + if v: + print(f"{h}: {v}") + print() + body = extract_body(msg) + print(body if body is not None else "<no body>") + finally: + M.logout() + + +def _store(uids, op, flags): + M = connect() + try: + _select(M, INBOX, readonly=False) + for uid in uids: + typ, data = M.uid("STORE", str(uid).encode(), op, flags) + if typ != "OK": + sys.exit(f"error: STORE {op} {flags} on uid {uid} failed: {data}") + print(f"ok: STORE {op} {flags} on {len(uids)} uid(s)") + finally: + M.logout() + + +def cmd_mark_read(args): + _store(args.uids, "+FLAGS", r"(\Seen)") + + +def cmd_mark_unread(args): + _store(args.uids, "-FLAGS", r"(\Seen)") + + +def cmd_star(args): + # Workflow convention: starring also marks read (matches the Gmail flow). + _store(args.uids, "+FLAGS", r"(\Flagged \Seen)") + + +def cmd_unstar(args): + _store(args.uids, "-FLAGS", r"(\Flagged)") + + +def cmd_trash(args): + M = connect() + try: + _select(M, INBOX, readonly=False) + moved = 0 + for uid in args.uids: + typ, data = M.uid("MOVE", str(uid).encode(), TRASH) + if typ != "OK": + # Fallback for servers without RFC 6851 MOVE. + typ2, data2 = M.uid("COPY", str(uid).encode(), TRASH) + if typ2 != "OK": + sys.exit(f"error: COPY uid {uid} -> {TRASH} failed: {data2}") + M.uid("STORE", str(uid).encode(), "+FLAGS", r"(\Deleted)") + moved += 1 + M.expunge() + print(f"ok: moved {moved} uid(s) to {TRASH}") + finally: + M.logout() + + +def cmd_send(args): + # Resolve attachments first so a missing file fails before SMTP opens. + attachments = [load_attachment(Path(p)) for p in (args.attach or [])] + if args.body is not None: + body = args.body + elif args.body_file is not None: + body = Path(args.body_file).read_text() + else: + body = sys.stdin.read() + msg = build_message(USER, args.to, args.subject, body, attachments) + smtp = smtp_connect() + try: + smtp.send_message(msg) + print(f"ok: sent to {args.to}") + finally: + smtp.quit() + + +def cmd_folders(_args): + M = connect() + try: + typ, data = M.list() + if typ != "OK": + sys.exit(f"error: LIST failed: {data}") + for line in data: + print(line.decode("utf-8", errors="replace")) + finally: + M.logout() + + +def main(): + p = argparse.ArgumentParser(prog="cmail-action", + description="IMAP triage against Proton Bridge") + sp = p.add_subparsers(dest="cmd", required=True) + + p_list = sp.add_parser("list-unread", help="list unread INBOX messages as JSON") + p_list.add_argument("--limit", type=int, default=50, + help="cap to N most recent (default 50)") + p_list.set_defaults(func=cmd_list_unread) + + p_read = sp.add_parser("read", help="print headers + body of a UID") + p_read.add_argument("uid", type=int) + p_read.set_defaults(func=cmd_read) + + p_mr = sp.add_parser("mark-read") + p_mr.add_argument("uids", nargs="+", type=int) + p_mr.set_defaults(func=cmd_mark_read) + + p_mu = sp.add_parser("mark-unread") + p_mu.add_argument("uids", nargs="+", type=int) + p_mu.set_defaults(func=cmd_mark_unread) + + p_s = sp.add_parser("star", help="star (sets \\Flagged + \\Seen)") + p_s.add_argument("uids", nargs="+", type=int) + p_s.set_defaults(func=cmd_star) + + p_us = sp.add_parser("unstar") + p_us.add_argument("uids", nargs="+", type=int) + p_us.set_defaults(func=cmd_unstar) + + p_t = sp.add_parser("trash", help="MOVE uid(s) to Trash") + p_t.add_argument("uids", nargs="+", type=int) + p_t.set_defaults(func=cmd_trash) + + p_f = sp.add_parser("folders", help="list IMAP folders (debug)") + p_f.set_defaults(func=cmd_folders) + + p_send = sp.add_parser("send", help="send an email via Bridge SMTP") + p_send.add_argument("--to", required=True, help="recipient address") + p_send.add_argument("--subject", required=True) + body_group = p_send.add_mutually_exclusive_group() + body_group.add_argument("--body", help="body text inline") + body_group.add_argument("--body-file", help="path to a file whose " + "contents become the body") + p_send.add_argument("--attach", action="append", default=[], + help="path to attach (repeatable)") + p_send.set_defaults(func=cmd_send) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover new file mode 100755 index 0000000..152cf27 --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Enumerate cross-agent destinations: local projects + tailnet peers. + +See cross-agent-discover.md. Local: scan ~/projects/*/.ai/. Peers: read +peers.toml, SSH-probe each for reachability. --enumerate-remote optionally +runs `ls -d ~/projects/*/.ai/` over SSH to list remote projects. + +Cache results for 5 min at ~/.cache/cross-agent-comms/discovery.json so +repeated invocations don't re-probe. + +HALT: prints a banner; otherwise continues. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import subprocess +import sys +import time +import tomllib +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +PEERS_TOML = CONFIG_DIR / "peers.toml" +HALT_FILE = CONFIG_DIR / "HALT" +CACHE_DIR = Path.home() / ".cache" / "cross-agent-comms" +CACHE_FILE = CACHE_DIR / "discovery.json" +CACHE_TTL_SECONDS = 300 + +EXIT_OK = 0 +EXIT_GENERAL = 1 +EXIT_PEERS_TOML = 1 + + +def err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def render_banner_if_halt() -> None: + if not HALT_FILE.exists(): + return + try: + reason = HALT_FILE.read_text().strip() + except OSError: + reason = "(HALT file unreadable; treated as halted)" + print("⚠ HALT ACTIVE — cross-agent comms paused") + if reason: + print(f" reason: {reason}") + print() + + +def enumerate_local_projects() -> list[str]: + projects_dir = Path.home() / "projects" + if not projects_dir.is_dir(): + return [] + found = [] + for child in sorted(projects_dir.iterdir()): + if child.is_dir() and (child / ".ai").is_dir(): + found.append(child.name) + return found + + +def load_peers() -> dict: + if not PEERS_TOML.exists(): + return {"peers": {}} + try: + return tomllib.loads(PEERS_TOML.read_text()) + except (tomllib.TOMLDecodeError, OSError) as e: + err(f"cannot parse peers.toml: {e}") + sys.exit(EXIT_PEERS_TOML) + + +def probe_peer_reachability(host: str, ssh_user: str | None) -> tuple[bool, str | None]: + """Run a short SSH probe with BatchMode=yes (no interactive prompt).""" + target = f"{ssh_user}@{host}" if ssh_user else host + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=2", "-o", "BatchMode=yes", target, "true"], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "ssh probe failed" + if result.returncode == 0: + return True, None + return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] + + +def enumerate_remote_projects(host: str, ssh_user: str | None) -> list[str] | None: + target = f"{ssh_user}@{host}" if ssh_user else host + try: + result = subprocess.run( + [ + "ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, + "ls -d ~/projects/*/.ai/ 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + projects = [] + for line in result.stdout.splitlines(): + # Each line looks like /home/<user>/projects/<name>/.ai/ + parts = line.rstrip("/").split("/") + if len(parts) >= 2 and parts[-1] == ".ai": + projects.append(parts[-2]) + return projects + + +def read_cache() -> dict | None: + if not CACHE_FILE.exists(): + return None + try: + age = time.time() - CACHE_FILE.stat().st_mtime + if age > CACHE_TTL_SECONDS: + return None + return json.loads(CACHE_FILE.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def write_cache(payload: dict) -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + CACHE_FILE.write_text(json.dumps(payload, indent=2)) + + +def discover(peer_filter: str | None, enumerate_remote: bool) -> dict: + local = enumerate_local_projects() + peers_cfg = load_peers().get("peers", {}) + + peers_out = [] + for name, cfg in sorted(peers_cfg.items()): + if peer_filter and name != peer_filter: + continue + host = cfg.get("host", name) + ssh_user = cfg.get("ssh_user") + reachable, error = probe_peer_reachability(host, ssh_user) + entry = { + "name": name, + "host": host, + "reachable": reachable, + } + if not reachable: + entry["error"] = error + if enumerate_remote and reachable: + entry["projects"] = enumerate_remote_projects(host, ssh_user) or [] + peers_out.append(entry) + + return { + "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "halt_active": HALT_FILE.exists(), + "local": local, + "peers": peers_out, + } + + +def render_table(payload: dict, enumerate_remote: bool) -> None: + local = payload.get("local", []) + print(f"Local ({_local_hostname()}):") + if local: + wrapped = ", ".join(local) + print(f" {wrapped} [{len(local)} project{'s' if len(local) != 1 else ''}]") + else: + print(" (no projects with .ai/ found)") + print() + + peers = payload.get("peers", []) + if not peers: + print("Peers (from peers.toml):") + print(" (no peers configured)") + return + + print("Peers (from ~/.config/cross-agent-comms/peers.toml):") + for p in peers: + marker = "✓ reachable" if p.get("reachable") else f"✗ UNREACHABLE ({p.get('error', 'unknown')})" + print(f" {p['name']:<16} {p['host']:<24} {marker}") + if enumerate_remote and p.get("projects"): + wrapped = ", ".join(p["projects"]) + print(f" projects: {wrapped}") + + +def _local_hostname() -> str: + import socket + return socket.gethostname().split(".")[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Discover cross-agent destinations.") + parser.add_argument("--enumerate-remote", action="store_true", + help="SSH into each peer and list ~/projects/*/.ai/") + parser.add_argument("--no-cache", action="store_true", help="Skip cache; force fresh probe") + parser.add_argument("--peer", help="Limit to a single peer name from peers.toml") + parser.add_argument("--json", action="store_true", help="Machine-readable output") + args = parser.parse_args() + + render_banner_if_halt() + + payload = None + if not args.no_cache: + cached = read_cache() + if cached is not None: + # Honor --peer filter on cached payload. + if args.peer: + cached["peers"] = [p for p in cached.get("peers", []) if p["name"] == args.peer] + payload = cached + + if payload is None: + payload = discover(args.peer, args.enumerate_remote) + if not args.no_cache and not args.peer: + # Only cache full (unfiltered) discoveries. + write_cache(payload) + + if args.json: + print(json.dumps(payload, indent=2)) + return EXIT_OK + + render_table(payload, args.enumerate_remote) + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover.md new file mode 100644 index 0000000..95134bb --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-discover.md @@ -0,0 +1,155 @@ +# cross-agent-discover + +**Purpose.** Enumerate available cross-agent destinations — local projects on +this machine and remote projects on tailnet peers. Validates SSH reachability +for cross-machine destinations before reporting them as usable. + +## Usage + +``` +cross-agent-discover [--enumerate-remote] [--no-cache] [--peer <name>] +``` + +No args required for the common case (local enumeration + peer reachability). + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--enumerate-remote` | off | SSH into each peer and list projects under `~/projects/*/.ai/`. Off by default because SSH adds latency; turn on when you want to see what's available on a remote machine you haven't fully configured. | +| `--no-cache` | off | Skip the 5-minute cache; force fresh discovery. | +| `--peer <name>` | (all) | Limit to a single peer from `peers.toml`. | +| `--json` | off | Machine-readable output. | + +## Output + +### Default + +``` +$ cross-agent-discover +Local (ratio): + career, claude-templates, clipper, danneel, documents, elibrary, + finances, health, homelab, jr-estate, kit, little-elisper, + philosophy, website [14 projects] + +Peers (from ~/.config/cross-agent-comms/peers.toml): + velox.local reachable (last seen 2 sec ago) + bastion.local UNREACHABLE (ssh exit 255: connection refused) +``` + +### With `--enumerate-remote` + +``` +$ cross-agent-discover --enumerate-remote +Local (ratio): + ... (as above) + +velox.local (reachable): + career, homelab [2 projects] +``` + +## Configuration + +Reads `~/.config/cross-agent-comms/peers.toml`: + +```toml +# Each peer is a remote machine reachable via SSH (typically over Tailscale). + +[peers.velox] +host = "velox.local" +ssh_user = "cjennings" + +[peers.bastion] +host = "bastion.local" +ssh_user = "cjennings" +``` + +Peers entries describe machines, NOT projects. Projects are enumerated +on-demand under `~/projects/*/.ai/` either locally or via SSH. + +## Cache + +Successful discovery results are cached at +`~/.cache/cross-agent-comms/discovery.json` for 5 minutes. Repeated invocations +within the window read from cache. + +`--no-cache` forces a fresh probe. Useful when adding a new peer or after a +network change. + +## SSH reachability check + +For each peer, runs: + +``` +ssh -o ConnectTimeout=2 -o BatchMode=yes <user>@<host> true +``` + +`BatchMode=yes` prevents interactive password prompts — peers that don't have +key-based auth set up are reported as UNREACHABLE. + +If `--enumerate-remote` is set, on success runs: + +``` +ssh <user>@<host> 'ls -d ~/projects/*/.ai/ 2>/dev/null' +``` + +## Failure modes + +| Symptom | Likely cause | Fix | +|---|---|---| +| Peer reported UNREACHABLE | Tailscale not connected, SSH key not authorized, host firewalled | `tailscale status`; `ssh -v <peer>` to debug. | +| Local list is empty | Glob misresolved, or `~/projects/` doesn't exist | Check `ls -d ~/projects/*/.ai/`. | +| `--enumerate-remote` slow | Cold cache, slow tailnet, many peers | First run is slow, subsequent runs hit cache. Use `--peer <name>` to scope. | +| Peer unexpectedly missing from output | Not in `peers.toml`, or `peers.toml` malformed | `cat ~/.config/cross-agent-comms/peers.toml` and validate. | + +## HALT awareness + +Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a +prominent banner before normal output: + +``` +$ cross-agent-discover +⚠ HALT ACTIVE — cross-agent comms paused + Reason: <reason from HALT file body, if any> + Resume with: cross-agent-resume + +(enumeration continues normally — HALT does not suppress visibility) + +Local (ratio): + career, claude-templates, ... + +Peers: + velox.local reachable +``` + +Discover is read-only. Like `cross-agent-status`, it always runs so the user +keeps visibility into what destinations exist regardless of halt state. The +banner makes the halt state impossible to miss. + +If the HALT file exists but is unreadable, print a warning banner and +continue. + +See `cross-agent-halt.md` for the full halt mechanism. + +## Examples + +```bash +# Common: see what's available +cross-agent-discover + +# Force fresh probe after network change +cross-agent-discover --no-cache + +# What's on velox specifically +cross-agent-discover --peer velox --enumerate-remote + +# Pipe to grep +cross-agent-discover --json | jq '.peers[] | select(.reachable)' +``` + +## See also + +- `cross-agent-send` — uses `peers.toml` for routing destinations. +- `cross-agent-status` — local pending messages. +- `cross-agent-comms.org` — protocol spec, `* Limitations` section + explains the cross-machine model. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt new file mode 100755 index 0000000..df25115 --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Failsafe halt for cross-agent comms. + +See cross-agent-halt.md. Touches ~/.config/cross-agent-comms/HALT and stops +the cross-agent-watch systemd user service. With --tailnet, propagates the +HALT file to every peer in peers.toml via SSH; reports per-peer status with +non-zero exit on partial halt. + +Does NOT pkill in-flight scripts — they detect HALT on next iteration and +stop themselves. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tomllib +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +HALT_FILE = CONFIG_DIR / "HALT" +PEERS_TOML = CONFIG_DIR / "peers.toml" + +EXIT_OK = 0 +EXIT_PARTIAL = 1 + + +def err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def write_halt_file(reason: str) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + HALT_FILE.write_text((reason + "\n") if reason else "") + + +def stop_watcher_service() -> None: + """Best-effort stop of the systemd watcher service. Failures are logged but not fatal.""" + try: + subprocess.run( + ["systemctl", "--user", "stop", "cross-agent-watch.path"], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + # Watcher service may not be installed — fine. + pass + + +def load_peers() -> dict: + if not PEERS_TOML.exists(): + return {} + try: + return tomllib.loads(PEERS_TOML.read_text()) + except (tomllib.TOMLDecodeError, OSError) as e: + err(f"cannot parse peers.toml: {e}") + return {} + + +def ssh_touch_halt(host: str, ssh_user: str | None, reason: str) -> tuple[bool, str]: + target = f"{ssh_user}@{host}" if ssh_user else host + # Build the remote command. Quote the reason carefully. + remote_cmd = ( + f"mkdir -p ~/.config/cross-agent-comms && " + f"printf %s {_sh_quote(reason)} > ~/.config/cross-agent-comms/HALT" + ) + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd], + capture_output=True, text=True, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "ssh unavailable or timed out" + if result.returncode == 0: + return True, "HALT file written" + return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] + + +def _sh_quote(s: str) -> str: + return "'" + s.replace("'", "'\"'\"'") + "'" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Halt all cross-agent comms on this machine (and optionally tailnet).") + parser.add_argument("reason", nargs="?", default="", help="Optional human-readable reason") + parser.add_argument("--tailnet", action="store_true", + help="Propagate HALT to every peer in peers.toml") + args = parser.parse_args() + + # Local halt. + write_halt_file(args.reason) + stop_watcher_service() + print("Halting locally ✓ (HALT file written)") + + if not args.tailnet: + print() + print(f"Halt active. Remove {HALT_FILE} or run cross-agent-resume to clear.") + print("Agent polling will stop within ~5 min (one cadence cycle).") + return EXIT_OK + + peers = load_peers().get("peers", {}) + if not peers: + print() + print("No peers configured in peers.toml — local-only halt complete.") + return EXIT_OK + + print() + successes = 1 # local already counted + failures = [] + for name, cfg in sorted(peers.items()): + host = cfg.get("host", name) + ssh_user = cfg.get("ssh_user") + ok, detail = ssh_touch_halt(host, ssh_user, args.reason) + marker = "✓" if ok else "✗" + print(f"Halting {host:<28} {marker} ({detail})") + if ok: + successes += 1 + else: + failures.append(f"{name} ({host}): {detail}") + + print() + total = len(peers) + 1 + if failures: + print(f"PARTIAL HALT: {successes}/{total} machines halted.") + for f in failures: + print(f" - {f}") + print("Resolve the failures or manually halt each machine.") + return EXIT_PARTIAL + print(f"Halt active across {total} machine(s).") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt.md new file mode 100644 index 0000000..b817fbc --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-halt.md @@ -0,0 +1,134 @@ +# cross-agent-halt + +**Purpose.** Failsafe stop for all cross-agent activity on the local machine +(or, with `--tailnet`, across all configured peers). Creates the HALT file +that every component in the protocol checks; within one polling cadence +(~5 min) all polling, sending, watching, and receiving stops. + +This is the user's emergency brake. Use when something is misbehaving and +visiting individual sessions is too slow. + +## Usage + +``` +cross-agent-halt [reason] [--tailnet] [--no-stop-watcher] +``` + +### Positional argument + +| Position | Meaning | Example | +|---|---|---| +| 1 | Optional human-readable reason for the halt. Written into the HALT file's body. Helps future-you remember why you stopped things. | `"investigating runaway poll loop, 2026-04-27"` | + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--tailnet` | local only | Propagate halt to every peer in `peers.toml` via SSH over Tailscale. | +| `--no-stop-watcher` | (stops watcher) | Skip stopping the `cross-agent-watch.path` systemd unit. Useful if the watcher is intentionally separate from comms (rare). | + +## Behavior + +### Local halt (default) + +1. Write the HALT file: `~/.config/cross-agent-comms/HALT`. If a `[reason]` was + passed, write it as the file's body. Otherwise the file is empty (existence + alone triggers halt). +2. Stop the watcher service: `systemctl --user stop cross-agent-watch.path` + (and the corresponding `.service` if running). +3. Print a summary: + ``` + ✓ HALT file written: ~/.config/cross-agent-comms/HALT + ✓ Watcher service stopped (cross-agent-watch.path) + - In-flight sends will complete their current rsync step (~seconds), then + stop. New sends are blocked. + - Active agent polling sessions stop within one cadence (~5 min). + - Use `cross-agent-resume` to clear HALT. + Per-session polling does NOT auto-resume — you re-engage each session by + telling its agent to resume polling. + ``` +4. Exit 0. + +### Cross-tailnet halt (`--tailnet`) + +1. Apply local halt steps 1-2 first. +2. Read `peers.toml` for the list of remote machines. +3. For each peer, SSH and write the HALT file: + ``` + ssh <user>@<host> "echo '<reason>' > ~/.config/cross-agent-comms/HALT && \ + systemctl --user stop cross-agent-watch.path" + ``` +4. Track per-peer success/failure. Print results: + ``` + Halting velox.local ✓ (HALT file written) + Halting bastion.local ✗ (ssh exit 255: no route to host) + Halting locally ✓ (HALT file written) + + PARTIAL HALT: 2/3 machines halted. bastion.local needs manual halt. + ``` +5. Exit 0 if all peers halted; exit 1 if any peer failed (so scripts can + detect partial halt). The local halt always succeeds — even on `--tailnet`, + if remote peers fail, local is still halted. + +## What "halt active" means for each component + +| Component | Behavior under HALT | +|---|---| +| `cross-agent-send` | Refuses to send. Exits 5 with "halt active; remove ~/.config/cross-agent-comms/HALT to resume." Checks HALT at start AND between each retry/rsync step, so an in-flight send completes its current step then stops. | +| `cross-agent-recv` | Refuses to verify or dedup. Exits 5 with same message. Inbound files are **left in place** — not moved, not rejected — so resume picks them up cleanly via cold-start. | +| `cross-agent-watch` | Continues running but suppresses notifications. Logs each event with `(suppressed by HALT)` so the operator can see what would have fired. | +| `cross-agent-status` | Prints prominent `⚠ HALT ACTIVE` banner before normal output. Continues to enumerate (read-only). | +| `cross-agent-discover` | Same banner. Continues (read-only). | +| Agent polling loops | Check HALT on every wake. If set: write a final `progress` note to any active conversation ("HALT fired locally; pausing"), surface "(HALT active; cross-agent comms paused)" in every user response, and stop rescheduling. Polling decays naturally within one cadence. | +| Conversation initiator | Refuses to write sequence 1 of any new conversation. Surfaces refusal to user. | +| Startup workflow (Phase A) | Checks HALT at session boot. If set, surfaces immediately and skips cross-agent inbox checks. | + +## Failure modes + +| Symptom | Cause | Fix | +|---|---|---| +| `~/.config/cross-agent-comms/HALT` already exists | Halt was already active | OK — running halt again refreshes the reason text. Safe. | +| `systemctl --user stop` fails | Watcher service not installed, or systemd not available | The HALT file is still written — components that check HALT will still stop. The systemctl failure surfaces as a non-fatal warning. | +| `--tailnet` halts some peers but not others | One or more peers unreachable | Exit 1 with per-peer status. Manually halt the unreachable peers (visit each machine, `touch ~/.config/cross-agent-comms/HALT`), or fix the network and re-run. | +| Permission denied writing the HALT file | `~/.config/cross-agent-comms/` doesn't exist or is owned by another user | `mkdir -p ~/.config/cross-agent-comms/`; check ownership. | + +## What halt does NOT do + +- Does not kill running Claude sessions. Polling stops within ~5 min, but the + session itself stays alive and can be re-engaged after resume. +- Does not delete pending messages. Inbound files in `inbox/from-agents/` + remain; they get processed when polling resumes. +- Does not abort in-flight rsync push mid-byte. Atomic-write semantics + guarantee in-flight messages either complete cleanly or leave only `.tmp.*` + files (which receivers ignore). + +## Examples + +```bash +# Quick halt with no reason +cross-agent-halt + +# Halt with a memo +cross-agent-halt "runaway poll loop in homelab session, debugging" + +# Halt all tailnet peers + local +cross-agent-halt --tailnet "shutting down for system update" + +# Halt protocol comms but leave the watcher service running +cross-agent-halt --no-stop-watcher +``` + +## Recovery + +Always pair with `cross-agent-resume` when the situation is resolved: + +```bash +cross-agent-resume # local +cross-agent-resume --tailnet # all peers +``` + +## See also + +- `cross-agent-resume` — counterpart that clears HALT. +- `cross-agent-status` — see HALT state at a glance. +- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv new file mode 100755 index 0000000..b67533a --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Cross-agent message receiver. + +See cross-agent-recv.md for the full contract. Reads one message file and +emits a structured decision the agent acts on: + + process | dedup | query | reject + +Decision exit codes: + 0 = process 1 = dedup 2 = query 3 = reject + +When HALT is set, the script refuses to verify or dedup and leaves the +inbound file in place — resume picks it up via cold-start. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +HALT_FILE = CONFIG_DIR / "HALT" +EXPECTED_PROTOCOL_VERSION = "5" + +REQUIRED_FRONTMATTER = ["TITLE", "CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"] +VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"} + +DEC_PROCESS = "process" +DEC_DEDUP = "dedup" +DEC_QUERY = "query" +DEC_REJECT = "reject" + +EXIT_FOR_DECISION = { + DEC_PROCESS: 0, + DEC_DEDUP: 1, + DEC_QUERY: 2, + DEC_REJECT: 3, +} + +EXIT_HALT = 5 + + +def err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def check_halt() -> None: + if HALT_FILE.exists(): + try: + reason = HALT_FILE.read_text().strip() + except OSError: + err("halt active (HALT file present but unreadable; treated as halted)") + sys.exit(EXIT_HALT) + msg = "halt active; leaving inbound message in place (resume will pick up)" + if reason: + msg = f"{msg}: {reason}" + err(msg) + sys.exit(EXIT_HALT) + + +def parse_frontmatter(path: Path) -> dict[str, str]: + try: + text = path.read_text() + except OSError as e: + return {"_parse_error": f"cannot read: {e}"} + fm: dict[str, str] = {} + for line in text.splitlines(): + line = line.rstrip() + if not line: + if fm: + break + continue + m = re.match(r"#\+([A-Z_]+):\s*(.*)", line) + if m: + fm[m.group(1)] = m.group(2).strip() + elif fm: + break + return fm + + +def emit_decision( + decision: str, + reason: str | None, + fm: dict[str, str], + sha256: str | None, + args: argparse.Namespace, +) -> int: + payload = { + "decision": decision, + "reason": reason, + "message_type": fm.get("MESSAGE_TYPE"), + "conversation_id": fm.get("CONVERSATION_ID"), + "sequence": fm.get("SEQUENCE"), + "timestamp": fm.get("TIMESTAMP"), + "sha256": sha256, + } + if args.json: + print(json.dumps(payload, indent=None if args.compact_json else 2)) + else: + print(f"decision: {decision}") + if reason: + print(f"reason: {reason}") + for k in ("message_type", "conversation_id", "sequence", "timestamp"): + v = payload[k] + if v is not None: + print(f"{k}: {v}") + if sha256: + print(f"sha256: {sha256}") + return EXIT_FOR_DECISION[decision] + + +def gpg_verify(message_path: Path, sig_path: Path) -> tuple[bool, str]: + try: + result = subprocess.run( + ["gpg", "--verify", str(sig_path), str(message_path)], + capture_output=True, + text=True, + ) + except FileNotFoundError: + return False, "gpg not installed" + if result.returncode == 0: + return True, "" + return False, result.stderr.strip().splitlines()[-1] if result.stderr.strip() else f"exit {result.returncode}" + + +def sha256_of(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def find_dedup_match(message_path: Path, fm: dict[str, str], my_hash: str) -> tuple[str, str | None]: + """Scan the message's directory for same-CONVERSATION_ID/SEQUENCE files. + + Returns (decision, reason) — decision is DEC_DEDUP for an exact-hash match, + or DEC_PROCESS when no match or hash differs (sequence collision is OK). + """ + parent = message_path.parent + conv_id = fm["CONVERSATION_ID"] + sequence = fm["SEQUENCE"] + for sibling in parent.iterdir(): + if sibling == message_path or not sibling.is_file() or sibling.suffix != ".org": + continue + sib_fm = parse_frontmatter(sibling) + if sib_fm.get("CONVERSATION_ID") != conv_id or sib_fm.get("SEQUENCE") != sequence: + continue + # Same conv-id + same sequence — check hash. + if sha256_of(sibling) == my_hash: + return DEC_DEDUP, f"identical retry of {sibling.name}" + return DEC_PROCESS, None + + +def check_requires_tools(fm: dict[str, str]) -> tuple[bool, list[str]]: + """REQUIRES_TOOLS is a comma-separated list of tool names. + + For v5, "tool available" is a heuristic: an executable on PATH whose name + matches the tool slug. MCP availability is currently out of scope (no + portable way to query it from a CLI). + """ + tools_field = fm.get("REQUIRES_TOOLS") + if not tools_field: + return True, [] + tools = [t.strip() for t in tools_field.split(",") if t.strip()] + missing = [t for t in tools if shutil.which(t) is None] + return len(missing) == 0, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description="Receive and decide on a cross-agent message.") + parser.add_argument("message_file", type=Path) + parser.add_argument("--no-verify", action="store_true", help="Skip GPG verification (testing only)") + parser.add_argument("--no-dedup", action="store_true", help="Skip SHA-256 dedup against existing files") + parser.add_argument("--protocol-version", default=EXPECTED_PROTOCOL_VERSION, + help="Override expected protocol version (default: 5)") + parser.add_argument("--json", action="store_true", help="Emit JSON output") + parser.add_argument("--compact-json", action="store_true", help="Compact JSON (no indent)") + args = parser.parse_args() + + check_halt() + + if not args.message_file.is_file(): + err(f"message file not found: {args.message_file}") + return EXIT_FOR_DECISION[DEC_REJECT] + + fm = parse_frontmatter(args.message_file) + if "_parse_error" in fm: + return emit_decision(DEC_REJECT, fm["_parse_error"], {}, None, args) + + # Step 1: frontmatter sanity-check. + missing = [k for k in REQUIRED_FRONTMATTER if k not in fm] + if missing: + return emit_decision( + DEC_REJECT, f"frontmatter missing required fields: {', '.join(missing)}", fm, None, args + ) + if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES: + return emit_decision( + DEC_REJECT, f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}", fm, None, args + ) + + # Step 2: PROTOCOL_VERSION check. + if fm["PROTOCOL_VERSION"] != args.protocol_version: + return emit_decision( + DEC_QUERY, + f"PROTOCOL_VERSION mismatch: expected {args.protocol_version}, got {fm['PROTOCOL_VERSION']}", + fm, + None, + args, + ) + + # Step 3: GPG verify. + if not args.no_verify: + sig_path = args.message_file.with_suffix(args.message_file.suffix + ".asc") + if not sig_path.is_file(): + return emit_decision(DEC_REJECT, f"signature file missing: {sig_path.name}", fm, None, args) + ok, gpg_err = gpg_verify(args.message_file, sig_path) + if not ok: + return emit_decision(DEC_REJECT, f"gpg verify failed: {gpg_err}", fm, None, args) + + # Step 4: SHA-256 dedup. + my_hash = sha256_of(args.message_file) + if not args.no_dedup: + decision, reason = find_dedup_match(args.message_file, fm, my_hash) + if decision == DEC_DEDUP: + return emit_decision(DEC_DEDUP, reason, fm, my_hash, args) + + # Step 5: REQUIRES_TOOLS check. + ok, missing_tools = check_requires_tools(fm) + if not ok: + return emit_decision( + DEC_QUERY, + f"required tools unavailable: {', '.join(missing_tools)}", + fm, + my_hash, + args, + ) + + # Step 6: process. + return emit_decision(DEC_PROCESS, None, fm, my_hash, args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv.md new file mode 100644 index 0000000..247a27a --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-recv.md @@ -0,0 +1,218 @@ +# cross-agent-recv + +**Purpose.** The canonical receiver-side processor. Reads a single incoming +message file and reports a structured decision the agent acts on: +process / dedup / query / reject. + +The script handles only mechanical checks (frontmatter, signature, dedup, +version, tools). Substance-level decisions like `pushback` ("I disagree with +this request") happen one layer up — after the agent reads the message body +the script returns as `process`-able. + +This is the read-side counterpart to `cross-agent-send`. Together they are the +two halves of the per-message contract. The agent's polling loop calls +`cross-agent-recv` on every new file in `inbox/from-agents/` and dispatches on +the decision. + +Without this script, every receiver implementation re-invents GPG verify + +frontmatter sanity-check + SHA-256 dedup. With it, behavior is consistent +across projects. + +## Usage + +``` +cross-agent-recv <message-file> +``` + +Single positional argument: a `.org` file in `inbox/from-agents/`. The matching +`.asc` signature file must be present alongside it. + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--no-verify` | (verify on) | Skip GPG verification. Testing only. | +| `--no-dedup` | (dedup on) | Skip SHA-256 dedup against existing files. Testing only. | +| `--protocol-version <N>` | 5 | Override the expected protocol version. Useful for testing forward-compatibility checks. | +| `--json` | off | Output decision as JSON for easier parsing by the agent. | + +## Behavior + +Runs the receiver checks in order. First failure determines the decision. + +### Step 1 — Frontmatter sanity-check + +Parse the message's org-mode frontmatter. Required fields: + +- `#+TITLE` +- `#+CONVERSATION_ID` +- `#+MESSAGE_TYPE` (must be one of: `request`, `progress`, `query`, `pushback`, + `complete`, `release`, `escalate`) +- `#+SEQUENCE` (integer) +- `#+TIMESTAMP` (ISO 8601 with explicit offset) +- `#+PROTOCOL_VERSION` (must match the expected version; default 5) + +Any required field missing, malformed, or the protocol version mismatched → +decision = `reject` (frontmatter) or `query` (version mismatch — see below). + +### Step 2 — Protocol-version check + +If `PROTOCOL_VERSION` doesn't match the expected: + +- Decision = `query`. Action: receiver should write a `query` reply asking the + sender to upgrade to the expected protocol version. + +### Step 3 — Signature verification + +Look for `<message-file>.asc` alongside the `.org`. If missing or `gpg +--verify` fails: + +- Decision = `reject` (signature). Surface to user; do not act. + +The `.asc` file MUST be present when the `.org` is — `cross-agent-send` +guarantees this with its strict ordering (`.asc` lands first). If the `.asc` +is missing despite the `.org` being present, the sender violated atomic-write +ordering or the file was tampered with in transit. + +### Step 4 — SHA-256 dedup + +Compute SHA-256 of the message file. Scan the same directory for existing +files matching `CONVERSATION_ID + SEQUENCE`: + +- No match → decision = `process` (new message, dispatch by type). +- Match with **identical** SHA-256 → decision = `dedup` (silent retry; do not + reprocess). +- Match with **different** SHA-256 → decision = `process` (sequence collision + with non-identical content; both are legitimate, ordered by `#+TIMESTAMP`). + +### Step 5 — REQUIRES_TOOLS optional check + +If the message has a `#+REQUIRES_TOOLS` field, verify each named tool/MCP is +available in the receiver's environment. + +- All available → `process`. +- One or more missing → decision = `query`. The agent should write a `query` + reply naming the missing tools, asking the sender to reframe the request to + avoid them. + +### Step 6 — Dispatch decision + +If all checks pass, decision = `process` with the parsed `MESSAGE_TYPE` so the +agent's main loop knows which handler to invoke. + +## Output + +### Default (human-readable) + +``` +$ cross-agent-recv inbox/from-agents/20260427T091015Z-from-homelab-prep-fixup.org +decision: process +message_type: request +conversation_id: prep-fixup +sequence: 6 +sha256: a1b2c3d4... +``` + +### `--json` + +```json +{ + "decision": "process", + "reason": null, + "message_type": "request", + "conversation_id": "prep-fixup", + "sequence": 6, + "timestamp": "2026-04-27T04:11:42-05:00", + "sha256": "a1b2c3d4..." +} +``` + +For decisions other than `process`, `reason` carries a human-readable +explanation: + +```json +{ + "decision": "query", + "reason": "PROTOCOL_VERSION mismatch: expected 5, got 4", + "conversation_id": "prep-fixup", + "sequence": 6 +} +``` + +## Decision exit codes + +| Decision | Exit code | Agent action | +|---|---|---| +| `process` | 0 | Dispatch to the message-type handler | +| `dedup` | 1 | Silent — do nothing further | +| `query` | 2 | Write a `query` reply (see `reason` for what to ask) | +| `reject` | 3 | Surface to user; do not auto-reply | + +The agent reads stdout/JSON to learn the decision; it can also key off exit +code for simpler bash-style dispatching. + +## Failure modes + +| Symptom | Cause | Fix | +|---|---|---| +| `decision: reject (frontmatter)` | Required field missing or malformed | Open the message; fix or surface to user. The sender should not have produced this file. | +| `decision: reject (signature)` | `.asc` missing, GPG verify failed, or signer unknown | Check that `.asc` exists alongside `.org`. If yes, run `gpg --verify <msg>.asc <msg>` manually for diagnostic output. | +| `decision: query (PROTOCOL_VERSION)` | Sender on older/newer protocol | Reply with a `query` asking sender to upgrade. Both sides should align before continuing. | +| `decision: query (REQUIRES_TOOLS)` | Receiver lacks one of the named tools | Reply with a `query` naming the missing tools; sender should reframe to avoid. | +| `decision: dedup` | Already-processed identical retry | No action. The script handled it correctly. | + +## HALT awareness + +Checks `~/.config/cross-agent-comms/HALT` at the start of every invocation. If +HALT exists, exits with code 5 ("halt active; remove +~/.config/cross-agent-comms/HALT to resume") without verifying, deduping, or +returning a decision. + +**The inbound file is left in place** — not moved, not rejected, not +deduped. When HALT clears and polling resumes, the file gets picked up via +the normal cold-start handling (whichever surfaces first: watcher +notification, startup workflow check, or the next agent poll). Reversibility +is preserved. + +If the HALT file exists but is unreadable, fail-closed — treat as if HALT is +set. + +See `cross-agent-halt.md` for the full halt mechanism. + +## Examples + +```bash +# Basic invocation in an agent's polling loop +for msg in inbox/from-agents/*.org; do + decision=$(cross-agent-recv --json "$msg") + case "$(echo "$decision" | jq -r '.decision')" in + process) handle_message "$msg" ;; + dedup) ;; # silent + query) write_query_reply "$msg" "$decision" ;; + reject) surface_to_user "$msg" "$decision" ;; + esac +done + +# Test signature verification only +cross-agent-recv --no-dedup inbox/from-agents/test-msg.org + +# Test against a future protocol version +cross-agent-recv --protocol-version 6 inbox/from-agents/future-msg.org +``` + +## Performance + +The script is fast (single SHA-256 compute, single GPG verify, frontmatter +parse). For typical messages (single-digit KB), runs in well under 100ms. +Dedup-scan is O(N) over files in the directory; if a project's +`inbox/from-agents/` accumulates hundreds of files, archive released +conversations to keep the scan fast. + +## See also + +- `cross-agent-send` — counterpart writer. +- `cross-agent-watch` — fires when a new message arrives; agent then calls + `cross-agent-recv` to process it. +- `cross-agent-status` — pending-message snapshot (uses similar + released-vs-unreleased logic, but doesn't process individual messages). +- `cross-agent-comms.org` — protocol spec, the "what" the script implements. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume new file mode 100755 index 0000000..1fb83bc --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Resume cross-agent comms after a halt. + +See cross-agent-resume.md. Removes ~/.config/cross-agent-comms/HALT and +restarts the cross-agent-watch systemd user service. With --tailnet, +propagates the removal to every peer in peers.toml via SSH; reports +per-peer status with non-zero exit on partial resume. + +Per the asymmetry rule: clearing HALT does NOT auto-resume agent polling. +Each session must explicitly re-engage. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tomllib +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +HALT_FILE = CONFIG_DIR / "HALT" +PEERS_TOML = CONFIG_DIR / "peers.toml" + +EXIT_OK = 0 +EXIT_PARTIAL = 1 + + +def err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def remove_halt_file() -> bool: + """Returns True if HALT was removed, False if it didn't exist.""" + if HALT_FILE.exists(): + try: + HALT_FILE.unlink() + return True + except OSError as e: + err(f"could not remove HALT: {e}") + return False + return False + + +def start_watcher_service() -> None: + """Best-effort start of the systemd watcher path unit.""" + try: + subprocess.run( + ["systemctl", "--user", "start", "cross-agent-watch.path"], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def load_peers() -> dict: + if not PEERS_TOML.exists(): + return {} + try: + return tomllib.loads(PEERS_TOML.read_text()) + except (tomllib.TOMLDecodeError, OSError) as e: + err(f"cannot parse peers.toml: {e}") + return {} + + +def ssh_remove_halt(host: str, ssh_user: str | None) -> tuple[bool, str]: + target = f"{ssh_user}@{host}" if ssh_user else host + remote_cmd = "rm -f ~/.config/cross-agent-comms/HALT" + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd], + capture_output=True, text=True, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "ssh unavailable or timed out" + if result.returncode == 0: + return True, "HALT cleared" + return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1] + + +def print_re_engage_instructions() -> None: + print() + print("Halt cleared. Watcher restarted.") + print() + print("Agent polling does NOT auto-resume — per the failsafe asymmetry rule,") + print("agents stay paused until you explicitly re-engage each session.") + print("Open the relevant Claude session and tell the agent to resume polling") + print("for its conversation.") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resume cross-agent comms after a halt.") + parser.add_argument("--tailnet", action="store_true", + help="Propagate HALT removal to every peer in peers.toml") + args = parser.parse_args() + + removed = remove_halt_file() + start_watcher_service() + if removed: + print("Resuming locally ✓ (HALT cleared)") + else: + print("Resuming locally ✓ (no HALT was active)") + + if not args.tailnet: + print_re_engage_instructions() + return EXIT_OK + + peers = load_peers().get("peers", {}) + if not peers: + print() + print("No peers configured in peers.toml — local-only resume complete.") + print_re_engage_instructions() + return EXIT_OK + + print() + successes = 1 + failures = [] + for name, cfg in sorted(peers.items()): + host = cfg.get("host", name) + ssh_user = cfg.get("ssh_user") + ok, detail = ssh_remove_halt(host, ssh_user) + marker = "✓" if ok else "✗" + print(f"Resuming {host:<27} {marker} ({detail})") + if ok: + successes += 1 + else: + failures.append(f"{name} ({host}): {detail}") + + print() + total = len(peers) + 1 + if failures: + print(f"PARTIAL RESUME: {successes}/{total} machines cleared.") + for f in failures: + print(f" - {f}") + print("Resolve the failures or manually clear HALT on each machine.") + print_re_engage_instructions() + return EXIT_PARTIAL + + print(f"Resume complete across {total} machine(s).") + print_re_engage_instructions() + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume.md new file mode 100644 index 0000000..8aa8357 --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-resume.md @@ -0,0 +1,117 @@ +# cross-agent-resume + +**Purpose.** Clear the HALT file and restart the watcher service. Counterpart +to `cross-agent-halt`. Resuming agent polling is **explicit per-session** — +this script doesn't auto-revive halted polling loops; you tell each session +to re-engage. + +## Usage + +``` +cross-agent-resume [--tailnet] +``` + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--tailnet` | local only | Clear HALT on every peer in `peers.toml` via SSH over Tailscale. | + +## Behavior + +### Local resume (default) + +1. Remove the HALT file: `rm -f ~/.config/cross-agent-comms/HALT`. (Use `-f` + so a missing file isn't an error — running resume when not halted is safe.) +2. Restart the watcher service: `systemctl --user start cross-agent-watch.path`. +3. Print a summary: + ``` + ✓ HALT file removed + ✓ Watcher service started (cross-agent-watch.path) + - cross-agent-send and cross-agent-recv will accept new operations. + - Inbound messages held during halt will be picked up by the watcher. + - Agent polling does NOT auto-resume. To re-engage polling in a paused + session, open that Claude session and tell the agent to resume. + ``` +4. Exit 0. + +### Cross-tailnet resume (`--tailnet`) + +1. Apply local resume steps 1-2 first. +2. Read `peers.toml` for the list of remote machines. +3. For each peer, SSH: + ``` + ssh <user>@<host> "rm -f ~/.config/cross-agent-comms/HALT && \ + systemctl --user start cross-agent-watch.path" + ``` +4. Track per-peer success/failure: + ``` + Resuming velox.local ✓ (HALT cleared, watcher started) + Resuming bastion.local ✗ (ssh exit 255: no route to host) + Resuming locally ✓ + + PARTIAL RESUME: 2/3 machines resumed. bastion.local still halted. + ``` +5. Exit 0 if all peers resumed; exit 1 on any failure. + +## Why agent polling doesn't auto-resume + +Two reasons the asymmetry is deliberate: + +1. *Auto-resume could silently invert intentional kills.* If you halted + because a session was misbehaving, removing HALT shouldn't quietly revive + that session's polling. You re-engage explicitly so you're aware of which + sessions came back online. + +2. *You may want to inspect before resuming.* After a halt, you might want to + read pending messages, fix configuration, or kill a particular Claude + session entirely. Per-session resume forces that pause. + +## Re-engaging polling in a Claude session + +After `cross-agent-resume`, open the relevant Claude session and say something +like: + +``` +HALT is cleared; resume polling. +``` + +The agent will check the HALT file (now absent), re-create its polling +schedule, and continue the in-flight conversation from wherever it left off. +The conversation file is intact; the receiver will pick up any new messages +that arrived during the halt window. + +## Failure modes + +| Symptom | Cause | Fix | +|---|---|---| +| HALT file doesn't exist | Already resumed (or never halted) | OK — `-f` makes this a no-op. | +| `systemctl --user start` fails | Watcher service not installed | Install per `cross-agent-watch.md`'s systemd recipe. | +| `--tailnet` resumes some peers but not others | Same as halt: peer unreachable | Per-peer status reported; resolve manually for unreachable peers. | +| Permission denied removing HALT file | File owned by another user | Check ownership; HALT files should be owned by the running user. | + +## Examples + +```bash +# Local resume after a halt +cross-agent-resume + +# Resume all tailnet peers + local +cross-agent-resume --tailnet +``` + +## Recovery flow + +After a halt: + +1. Investigate whatever caused the halt (runaway loop, bad config, etc.). +2. Fix the underlying issue. +3. Run `cross-agent-resume`. +4. Open each Claude session that was polling and tell its agent to re-engage. +5. Confirm operation with `cross-agent-status`. + +## See also + +- `cross-agent-halt` — counterpart that creates the HALT file. +- `cross-agent-status` — verify HALT cleared and see pending messages. +- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send new file mode 100755 index 0000000..68c010a --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Cross-agent message sender. + +See cross-agent-send.md for the full contract. Briefly: + +- Destination as <machine>.<project>; resolved via peers.toml. +- Same-machine: cp to receiver's inbox/from-agents/ with atomic rename. +- Cross-machine: rsync over SSH (typically Tailscale) with retry+backoff. +- GPG-signs by default; .asc renames before .org so receivers never see + a .org without its sibling signature. +- Generates the canonical filename; user's input filename is ignored. +- Honors the HALT file: refuses to send and exits with code 5 when set. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import tomllib +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +PEERS_TOML = CONFIG_DIR / "peers.toml" +HALT_FILE = CONFIG_DIR / "HALT" +STATE_DIR = Path.home() / ".local" / "state" / "cross-agent-comms" +FAILED_SENDS_DIR = STATE_DIR / "failed-sends" + +EXIT_OK = 0 +EXIT_GENERAL = 1 +EXIT_DEST_NOT_FOUND = 2 +EXIT_CROSS_MACHINE_FAILED = 3 +EXIT_FRONTMATTER = 4 +EXIT_HALT = 5 + +REQUIRED_FRONTMATTER = ["CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"] +VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"} + + +def err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def check_halt() -> None: + """Exit with code 5 if HALT file exists.""" + if HALT_FILE.exists(): + try: + reason = HALT_FILE.read_text().strip() + except OSError: + # Fail-closed on unreadable HALT. + err("halt active (HALT file present but unreadable; treated as halted)") + err(f"remove {HALT_FILE} to resume") + sys.exit(EXIT_HALT) + msg = "halt active" + if reason: + msg += f": {reason}" + err(msg) + err(f"remove {HALT_FILE} to resume") + sys.exit(EXIT_HALT) + + +def parse_frontmatter(path: Path) -> dict[str, str]: + """Extract org-mode #+KEY: value frontmatter from the top of the file.""" + try: + text = path.read_text() + except OSError as e: + err(f"cannot read message file: {e}") + sys.exit(EXIT_GENERAL) + + frontmatter: dict[str, str] = {} + for line in text.splitlines(): + line = line.rstrip() + if not line: + # Blank line ends the frontmatter block. + if frontmatter: + break + continue + m = re.match(r"#\+([A-Z_]+):\s*(.*)", line) + if m: + frontmatter[m.group(1)] = m.group(2).strip() + else: + # First non-frontmatter line ends parsing. + if frontmatter: + break + return frontmatter + + +def validate_frontmatter(fm: dict[str, str]) -> None: + missing = [k for k in REQUIRED_FRONTMATTER if k not in fm] + if missing: + err(f"frontmatter missing required fields: {', '.join(missing)}") + sys.exit(EXIT_FRONTMATTER) + if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES: + err(f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}; expected one of {sorted(VALID_MESSAGE_TYPES)}") + sys.exit(EXIT_FRONTMATTER) + try: + int(fm["SEQUENCE"]) + except ValueError: + err(f"SEQUENCE must be an integer; got {fm['SEQUENCE']!r}") + sys.exit(EXIT_FRONTMATTER) + + +def load_peers() -> dict: + if not PEERS_TOML.exists(): + return {} + try: + return tomllib.loads(PEERS_TOML.read_text()) + except (tomllib.TOMLDecodeError, OSError) as e: + err(f"cannot read {PEERS_TOML}: {e}") + sys.exit(EXIT_GENERAL) + + +def resolve_destination(dest: str, peers: dict) -> tuple[str, str, str | None, str | None]: + """Resolve <machine>.<project> to (machine, project, host, ssh_user). + + host is None for same-machine destinations. + """ + if "." not in dest: + err(f"destination must be <machine>.<project>; got {dest!r}") + sys.exit(EXIT_DEST_NOT_FOUND) + machine, project = dest.split(".", 1) + + local_hostname = socket.gethostname().split(".")[0] + is_local = machine == local_hostname or machine == "local" + + host = None + ssh_user = None + if not is_local: + peer_cfg = peers.get("peers", {}).get(machine) + if peer_cfg is None: + available = list(peers.get("peers", {}).keys()) + err(f"destination not found in peers.toml; available peers: {available or '(none)'}") + sys.exit(EXIT_DEST_NOT_FOUND) + host = peer_cfg.get("host", machine) + ssh_user = peer_cfg.get("ssh_user", os.environ.get("USER")) + + return machine, project, host, ssh_user + + +def resolve_inbox_path(project: str, peers: dict) -> str: + """Inbox path on the receiver. Defaults to ~/projects/<project>/inbox/from-agents.""" + proj_cfg = peers.get("projects", {}).get(project) + if proj_cfg and "inbox_path" in proj_cfg: + return os.path.expanduser(proj_cfg["inbox_path"]) + return f"~/projects/{project}/inbox/from-agents" + + +def derive_sender_project() -> str: + """Walk up from CWD looking for ~/projects/<name>/. + + Returns the project name if found; falls back to the basename of CWD. + """ + cwd = Path.cwd().resolve() + projects_root = (Path.home() / "projects").resolve() + try: + rel = cwd.relative_to(projects_root) + return rel.parts[0] + except ValueError: + return cwd.name + + +def generate_canonical_filename(sender: str, conv_id: str) -> str: + """YYYYMMDDTHHMMSSZ-from-<sender>-<conv-id>.org""" + now = _dt.datetime.now(_dt.timezone.utc) + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + return f"{timestamp}-from-{sender}-{conv_id}.org" + + +def sign(message_path: Path, sig_path: Path, key: str | None) -> None: + """gpg --detach-sign --armor --output <sig> [--local-user <key>] <message>""" + cmd = ["gpg", "--detach-sign", "--armor", "--yes", "--output", str(sig_path)] + if key: + cmd.extend(["--local-user", key]) + cmd.append(str(message_path)) + try: + result = subprocess.run(cmd, capture_output=True, text=True) + except FileNotFoundError: + err("gpg not found; install gnupg or use --no-sign for testing") + sys.exit(EXIT_GENERAL) + if result.returncode != 0: + err(f"signing failed: {result.stderr.strip()}") + sys.exit(EXIT_GENERAL) + + +def same_machine_deliver(message_path: Path, sig_path: Path | None, target_dir: Path, canonical_name: str) -> None: + """Atomic-write delivery: stage .asc, mv to final, then stage .org, mv to final.""" + target_dir.mkdir(parents=True, exist_ok=True) + final_msg = target_dir / canonical_name + final_sig = target_dir / f"{canonical_name}.asc" + + if sig_path is not None: + # Stage .asc first, mv to final, THEN stage .org and mv to final. + with tempfile.NamedTemporaryFile( + mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.asc.", delete=False + ) as tmp: + tmp.write(sig_path.read_bytes()) + tmp_sig_path = Path(tmp.name) + os.replace(tmp_sig_path, final_sig) + + # Re-check HALT between .asc and .org per the layered-checks rule. + check_halt() + + with tempfile.NamedTemporaryFile( + mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.", delete=False + ) as tmp: + tmp.write(message_path.read_bytes()) + tmp_msg_path = Path(tmp.name) + os.replace(tmp_msg_path, final_msg) + + +def cross_machine_deliver( + message_path: Path, + sig_path: Path | None, + canonical_name: str, + host: str, + ssh_user: str, + inbox_path: str, + retries: int, +) -> bool: + """rsync push the .asc first (if signed), re-check HALT, then push the .org. + + Returns True on success, False on persistent failure (after retries). + """ + # Stage local copies with the canonical name so rsync sets the right + # destination filename. + with tempfile.TemporaryDirectory(prefix="cross-agent-send-") as staging: + staging_dir = Path(staging) + local_msg = staging_dir / canonical_name + local_msg.write_bytes(message_path.read_bytes()) + local_sig = None + if sig_path is not None: + local_sig = staging_dir / f"{canonical_name}.asc" + local_sig.write_bytes(sig_path.read_bytes()) + + backoffs = [5, 30, 120] + # Step 1: push .asc first if signed. + if local_sig is not None: + if not _rsync_with_retries(local_sig, host, ssh_user, inbox_path, retries, backoffs): + return False + + # Re-check HALT between .asc and .org per the layered-checks rule. + check_halt() + + # Step 2: push .org. + if not _rsync_with_retries(local_msg, host, ssh_user, inbox_path, retries, backoffs): + return False + + return True + + +def _rsync_with_retries( + src: Path, host: str, ssh_user: str, inbox_path: str, retries: int, backoffs: list[int] +) -> bool: + target = f"{ssh_user}@{host}:{inbox_path}/" + last_err = "" + for attempt in range(retries + 1): + if attempt > 0: + check_halt() + wait = backoffs[min(attempt - 1, len(backoffs) - 1)] + err(f"rsync attempt {attempt} failed: {last_err}; retrying in {wait}s") + time.sleep(wait) + try: + result = subprocess.run( + ["rsync", "-a", str(src), target], + capture_output=True, + text=True, + ) + except FileNotFoundError: + err("rsync not found; install rsync") + return False + if result.returncode == 0: + return True + last_err = result.stderr.strip() or f"exit {result.returncode}" + err(f"rsync failed after {retries + 1} attempts: {last_err}") + return False + + +def write_failed_send_marker(dest: str, message_path: Path, error: str, retry_log: list[str]) -> None: + FAILED_SENDS_DIR.mkdir(parents=True, exist_ok=True) + timestamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + safe_basename = re.sub(r"[^A-Za-z0-9._-]", "_", message_path.name) + marker = FAILED_SENDS_DIR / f"{timestamp}-{dest.replace('.', '-')}-{safe_basename}.json" + marker.write_text(json.dumps( + { + "timestamp": timestamp, + "destination": dest, + "message_path": str(message_path), + "error": error, + "retry_log": retry_log, + }, + indent=2, + )) + err(f"marker written: {marker}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Send a cross-agent message.") + parser.add_argument("destination", help="Destination as <machine>.<project>") + parser.add_argument("message_file", type=Path, help="Path to the message body file") + parser.add_argument("--no-sign", action="store_true", help="Skip GPG signing (testing only)") + parser.add_argument("--retries", type=int, default=3, help="Retry count for cross-machine sends") + parser.add_argument("--key", help="GPG key id to sign with (default: user's primary)") + args = parser.parse_args() + + check_halt() + + if not args.message_file.is_file(): + err(f"message file not found: {args.message_file}") + return EXIT_GENERAL + + fm = parse_frontmatter(args.message_file) + validate_frontmatter(fm) + + peers = load_peers() + machine, project, host, ssh_user = resolve_destination(args.destination, peers) + inbox_path = resolve_inbox_path(project, peers) + + sender = derive_sender_project() + canonical_name = generate_canonical_filename(sender, fm["CONVERSATION_ID"]) + + sig_tmp = None + if not args.no_sign: + sig_tmp = args.message_file.with_suffix(args.message_file.suffix + ".asc.tmp") + sign(args.message_file, sig_tmp, args.key) + + try: + if host is None: + # Same-machine delivery. + target_dir = Path(os.path.expanduser(inbox_path)) + same_machine_deliver(args.message_file, sig_tmp, target_dir, canonical_name) + print(f"sent: {target_dir}/{canonical_name}") + return EXIT_OK + else: + ok = cross_machine_deliver( + args.message_file, sig_tmp, canonical_name, host, ssh_user, inbox_path, args.retries + ) + if ok: + print(f"sent: {ssh_user}@{host}:{inbox_path}/{canonical_name}") + return EXIT_OK + write_failed_send_marker(args.destination, args.message_file, "rsync failed after retries", []) + return EXIT_CROSS_MACHINE_FAILED + finally: + if sig_tmp is not None and sig_tmp.exists(): + sig_tmp.unlink() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send.md new file mode 100644 index 0000000..29bfb24 --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-send.md @@ -0,0 +1,199 @@ +# cross-agent-send + +**Purpose.** Send a cross-agent message file to a specific destination. Handles +peer-config lookup, GPG signing, atomic write (same-machine) or rsync push +(cross-machine), retry-with-backoff, and failure surfacing. + +This is the canonical writer. The protocol spec defers all writer mechanics to +this script. + +## Usage + +``` +cross-agent-send <destination> <message-file> [--no-sign] [--retries N] +``` + +### Positional arguments + +| Position | Meaning | Example | +|---|---|---| +| 1 | Destination as `<machine>.<project>` | `homelab.career`, `velox.career` | +| 2 | Message file (already-formatted `.org`) | `/tmp/my-message.org` | + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--no-sign` | (signing on) | Skip GPG signing. Use only for testing; receivers reject unsigned messages by default. | +| `--retries N` | 3 | Override retry count for cross-machine sends. | +| `--key <key-id>` | (user's primary key) | GPG key to sign with. Resolution order: `--key` flag, `GPG_USER` env, `git config user.signingkey`, then the first secret key in the keyring. | + +## Behavior + +### Filename generation (script-controlled) + +The script generates the canonical destination filename from the message's +frontmatter and sender context. The user's input filename is ignored — pass any +path, the script names the destination correctly: + +``` +<UTC-now>T<HHMMSS>Z-from-<sender-slug>-<short-conv-id>.org +``` + +`<sender-slug>` comes from the sender machine's project name (config or +hostname-based). `<short-conv-id>` is read from the message's +`#+CONVERSATION_ID` frontmatter field. UTC timestamp is generated at send time. + +The script also performs the **sender-side max-seen scan** before writing: it +reads the receiver's `from-agents/` directory, finds the highest existing +sequence in this conversation across both sender prefixes, and (best-effort) +suggests `max(seen) + 1` for the next sequence. The user/agent is responsible +for setting `#+SEQUENCE` in the message body; the script only advises. + +### Same-machine destinations + +Resolved when the destination's machine matches the current hostname (or is +not in `peers.toml` as a remote). Steps: + +1. Parse frontmatter; extract `CONVERSATION_ID` and `TIMESTAMP`. Validate per + the *Validation before send* section below. +2. Generate canonical filename per *Filename generation* above. +3. Sign: `gpg --detach-sign --armor --output <canonical>.asc --local-user <key> <input>`. +4. Compute target: read `peers.toml` for the project's `inbox_path`. If + missing, fall back to `~/projects/<project>/inbox/from-agents/`. +5. **Atomic write with strict ordering** (signature must precede message): + - Stage `.asc`: write to `<target>/.tmp.XXXXXX-<canonical>.asc`, + then `mv` to `<target>/<canonical>.asc`. + - **Then** stage `.org`: write to `<target>/.tmp.XXXXXX-<canonical>`, + then `mv` to `<target>/<canonical>`. + - Receivers only act on `.org` files; staging the `.asc` first guarantees + the signature is present when the receiver opens the message. Out-of-order + would race: receiver could read the `.org` before the `.asc` lands and + fail GPG verify even though the sender did everything right. +6. Exit 0 on success. Exit non-zero if any step fails. + +### Cross-machine destinations + +Steps: + +1. Parse + generate canonical filename, as same-machine steps 1-2. +2. Sign locally to `<input>.asc` (or a tmp staging file). +3. rsync push **with the same .asc-first ordering**: + - `rsync -a <input>.asc <ssh-user>@<host>:<inbox_path>/<canonical>.asc` + - **Then** `rsync -a <input> <ssh-user>@<host>:<inbox_path>/<canonical>` + rsync writes to a hidden temp file then renames atomically by default + (`--inplace` would defeat this; do not pass it). +4. Retry on failure: 5s, 30s, 120s backoff, then surface error. +5. On persistent failure: write a marker file to + `~/.local/state/cross-agent-comms/failed-sends/<timestamp>-<dest>-<canonical>.json` + containing the destination, message path, error, and retry log. Exit non-zero. + +### Validation before send + +- Destination resolves via `peers.toml` (or local fallback). If neither, exit + immediately with `destination not found in peers.toml; available: <list>`. +- Message file must be readable, non-empty, and have valid org-mode frontmatter + with **all** of the following required fields: + - `#+TITLE` + - `#+CONVERSATION_ID` + - `#+MESSAGE_TYPE` + - `#+SEQUENCE` + - `#+TIMESTAMP` + - `#+PROTOCOL_VERSION` (must equal `5` for v5) + + If any required field is missing or malformed, exit immediately with a parse + error naming the offending field. + +- Optional fields the script recognizes and passes through (no special + handling beyond preservation): + - `#+REQUIRES_TOOLS` — comma-separated tool/MCP slugs the receiver needs. + - `#+RELEASE_STATUS` — valid only on `MESSAGE_TYPE: release`. Values per + spec: `complete`, `cancelled`, `withdrawn-after-pushback`, + `abandoned-after-escalation`. + - `#+WORKFLOW_VERSION` — sender's version of the cross-agent-comms workflow + file. Currently advisory; receiver may warn on mismatch but does not block. + +## Configuration + +Reads `~/.config/cross-agent-comms/peers.toml` for peer routing: + +```toml +[peers.velox] +host = "velox.local" +ssh_user = "cjennings" + +# Optional: per-project inbox-path overrides for non-default layouts. +[projects.work] +inbox_path = "~/projects/work/inbox/from-agents" + +[projects.homelab] +inbox_path = "~/projects/homelab/inbox/from-agents" +``` + +If a project entry is omitted, defaults to `~/projects/<project>/inbox/from-agents`. + +## Failure modes + +| Symptom | Cause | Fix | +|---|---|---| +| `destination not found in peers.toml` | Misspelled destination, or peer not configured | Run `cross-agent-discover` to see available destinations. | +| `signing failed: no secret key` | GPG key missing or not in keyring | `gpg --list-secret-keys` to confirm. Override with `--key <id>`. | +| `signing failed: pinentry timed out` | Headless session, GUI pinentry unavailable | Confirm `pinentry-program` in `gpg-agent.conf` matches available pinentry. Per protocols.org, GUI pinentry works from Claude Code. | +| `rsync exit 255` | SSH unreachable | `cross-agent-discover --peer <name>` to confirm reachability. | +| `rsync exit 23` | Permission denied at destination | Check destination directory perms (`chmod 700`) and ownership. | +| Marker file written to `failed-sends/` | Persistent cross-machine failure | Inspect the marker's `error` field. After fixing, retry: `cross-agent-send <dest> <msg>` (the marker is for visibility; it does not auto-retry). | +| Receiver complains "unsigned message" | `--no-sign` was used in production | Don't use `--no-sign` outside testing. | + +## HALT awareness + +Checks `~/.config/cross-agent-comms/HALT` at the start of every send AND +between the `.asc` and `.org` rsync calls AND between each retry iteration. +On HALT exists, exits with code 5 ("halt active; remove +~/.config/cross-agent-comms/HALT to resume") without writing or pushing +further. + +Worst case: one in-flight send completes its current rsync step within a few +seconds before halt kicks in for the next step. New sends are blocked +immediately. No `pkill` needed — the per-iteration check stops things +naturally. + +If the HALT file exists but is unreadable (permissions wrong), fail-closed — +treat as if HALT is set. Safer than fail-open. + +See `cross-agent-halt.md` for the full halt mechanism. + +## Examples + +```bash +# Same-machine send +cross-agent-send homelab.career /tmp/my-message.org + +# Cross-machine send via Tailscale +cross-agent-send velox.career /tmp/my-message.org + +# Test send without signing (receiver will reject) +cross-agent-send homelab.career /tmp/test.org --no-sign + +# Override retry count for a flaky link +cross-agent-send velox.career /tmp/my-message.org --retries 10 + +# After a delivery failure, inspect the marker +cat ~/.local/state/cross-agent-comms/failed-sends/*.json | jq . +``` + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Sent successfully. | +| 1 | General error (parse failure, signing failure, etc.). | +| 2 | Destination not found in peers.toml. | +| 3 | Cross-machine delivery failed after retries. Marker file written. | +| 4 | Frontmatter validation failed. | + +## See also + +- `cross-agent-discover` — validate destinations before sending. +- `cross-agent-watch` — receiver-side notification. +- `cross-agent-status` — see what's queued. +- `cross-agent-comms.org` — protocol spec, the "what" the script implements. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status new file mode 100755 index 0000000..4eee75b --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Point-in-time snapshot of pending cross-agent messages across local projects. + +See cross-agent-status.md. Pending = messages in inbox/from-agents/ whose +CONVERSATION_ID has no MESSAGE_TYPE: release at a later #+TIMESTAMP. + +HALT: prints a prominent banner before normal output, but continues to enumerate. +""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +import re +import sys +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms" +HALT_FILE = CONFIG_DIR / "HALT" +DEFAULT_GLOB = str(Path.home() / "projects" / "*" / "inbox" / "from-agents") + "/" + + +def parse_frontmatter(path: Path) -> dict[str, str]: + try: + text = path.read_text() + except OSError: + return {} + fm: dict[str, str] = {} + for line in text.splitlines(): + line = line.rstrip() + if not line: + if fm: + break + continue + m = re.match(r"#\+([A-Z_]+):\s*(.*)", line) + if m: + fm[m.group(1)] = m.group(2).strip() + elif fm: + break + return fm + + +def project_name_from_path(path: str) -> str: + """Walk up from path to find ~/projects/<name>/...""" + home = str(Path.home()) + parts = Path(path).parts + for i, part in enumerate(parts): + if part == "projects" and i + 1 < len(parts) and str(Path(*parts[: i + 1])) == os.path.join(home, "projects"): + return parts[i + 1] + # Fallback: dir three levels up from the .org file (project/inbox/from-agents/file.org) + return Path(path).parent.parent.parent.name + + +def scan_project(inbox_dir: Path) -> tuple[int, str | None, int | None]: + """Return (pending_count, most_recent_filename_or_None, most_recent_age_seconds_or_None).""" + if not inbox_dir.is_dir(): + return 0, None, None + + # Group .org files by CONVERSATION_ID, also collect release timestamps per conv. + org_files = sorted(inbox_dir.glob("*.org")) + if not org_files: + return 0, None, None + + by_conv: dict[str, list[tuple[str, str, Path]]] = {} # conv_id -> [(timestamp, msg_type, path)] + for f in org_files: + fm = parse_frontmatter(f) + conv = fm.get("CONVERSATION_ID") + ts = fm.get("TIMESTAMP") + mt = fm.get("MESSAGE_TYPE") + if not conv or not ts or not mt: + # Malformed file: count as pending under conv "_unparseable". + by_conv.setdefault("_unparseable", []).append(("", "request", f)) + continue + by_conv.setdefault(conv, []).append((ts, mt, f)) + + pending_files: list[Path] = [] + for conv, entries in by_conv.items(): + entries.sort(key=lambda e: e[0]) + # Find the latest release timestamp. + release_ts = None + for ts, mt, _f in entries: + if mt == "release" and (release_ts is None or ts > release_ts): + release_ts = ts + for ts, mt, f in entries: + if mt == "release": + continue + if release_ts is not None and ts <= release_ts: + continue + pending_files.append(f) + + if not pending_files: + return 0, None, None + + # Most-recent by mtime (proxy for arrival order). + most_recent = max(pending_files, key=lambda p: p.stat().st_mtime) + import time + age = int(time.time() - most_recent.stat().st_mtime) + return len(pending_files), most_recent.name, age + + +def fmt_age(seconds: int | None) -> str: + if seconds is None: + return "—" + if seconds < 60: + return f"{seconds}s ago" + if seconds < 3600: + return f"{seconds // 60} min ago" + if seconds < 86400: + return f"{seconds // 3600} hr ago" + return f"{seconds // 86400} day(s) ago" + + +def render_banner_if_halt() -> None: + if not HALT_FILE.exists(): + return + try: + reason = HALT_FILE.read_text().strip() + except OSError: + reason = "(HALT file unreadable; treated as halted)" + print("⚠ HALT ACTIVE — cross-agent comms paused") + if reason: + print(f" reason: {reason}") + print(f" clear: rm {HALT_FILE} (or: cross-agent-resume)") + print() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Snapshot of pending cross-agent messages across local projects.") + parser.add_argument("--json", action="store_true", help="Emit JSON output") + parser.add_argument("--projects-glob", default=DEFAULT_GLOB, + help=f"Glob for project from-agents dirs (default: {DEFAULT_GLOB})") + args = parser.parse_args() + + render_banner_if_halt() + + matched = sorted(glob.glob(args.projects_glob)) + rows = [] + for path in matched: + inbox = Path(path) + if not inbox.is_dir(): + continue + proj = project_name_from_path(path) + count, most_recent, age = scan_project(inbox) + rows.append({ + "name": proj, + "pending_count": count, + "most_recent": ( + {"filename": most_recent, "age_seconds": age} + if most_recent else None + ), + }) + + # Sort: pending-first, then alphabetical by name. + rows.sort(key=lambda r: (-r["pending_count"], r["name"])) + + if args.json: + import datetime as _dt + payload = { + "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "halt_active": HALT_FILE.exists(), + "projects": rows, + } + print(json.dumps(payload, indent=2)) + return 0 + + if not rows: + print("No projects with inbox/from-agents/ found — 0 pending.") + return 0 + + # Human-readable table. + name_w = max(len("project"), max(len(r["name"]) for r in rows)) + print(f"{'project':<{name_w}} pending most-recent") + for r in rows: + most_recent_str = "—" + if r["most_recent"]: + most_recent_str = f"{r['most_recent']['filename']} ({fmt_age(r['most_recent']['age_seconds'])})" + print(f"{r['name']:<{name_w}} {r['pending_count']:<7} {most_recent_str}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status.md new file mode 100644 index 0000000..070330c --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-status.md @@ -0,0 +1,139 @@ +# cross-agent-status + +**Purpose.** Point-in-time snapshot of pending cross-agent messages across +every project on this machine. Run from any terminal. No daemon required. + +This is the user-pull layer of the cold-start story — `cross-agent-watch` +pushes notifications, `cross-agent-status` lets the user query. + +## Usage + +``` +cross-agent-status [--json] [--projects-glob <glob>] +``` + +No args required. + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--json` | off (table) | Output as JSON for scripting. | +| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to scan. | + +## Output + +### Default (table) + +``` +$ cross-agent-status +project pending most-recent +career 0 — +claude-templates 0 — +clipper 0 — +homelab 1 20260427T085611Z-from-career-question.org (3 min ago) +finances 0 — +... (other 9 projects) +``` + +Sort: pending-first, then alphabetical. + +### `--json` + +```json +{ + "scanned_at": "2026-04-27T04:13:00-05:00", + "projects": [ + { + "name": "homelab", + "pending_count": 1, + "most_recent": { + "filename": "20260427T085611Z-from-career-question.org", + "age_seconds": 180 + } + }, + ... + ] +} +``` + +## Pending semantics + +A message is "pending" if it sits in `inbox/from-agents/` AND no +`MESSAGE_TYPE: release` exists for the same `CONVERSATION_ID` after it. + +Concretely: + +1. Scan each project's `inbox/from-agents/` for `.org` files. +2. Group by `CONVERSATION_ID` from frontmatter. +3. For each conversation, find the highest-`#+TIMESTAMP` message with + `MESSAGE_TYPE: release`. +4. Messages with `#+TIMESTAMP` after that release (or in conversations with no + release) count as pending. + +Files without parseable frontmatter are counted as pending and noted in the +output (single warning row per project). + +## Failure modes + +| Symptom | Likely cause | Fix | +|---|---|---| +| Project missing from output | Project's `.ai/` directory exists but `inbox/from-agents/` does not | Created lazily on first cross-agent message; `mkdir -p` to surface in output. | +| All projects show "0 pending" but you know one has messages | Glob misresolved, OR all messages are post-release | `cross-agent-status --projects-glob` with explicit path to confirm. | +| Warning row "N files unparseable in <project>" | Message file has invalid frontmatter | Open the file, fix or move out. | + +## Performance + +Scans every `.org` file in every watched directory. For Craig's setup (14 +projects, single-digit messages each), runs in <100ms. If a project +accumulates hundreds of post-release messages, archive them per the persistence +guidance in the protocol spec. + +## HALT awareness + +Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a +prominent banner before normal output: + +``` +$ cross-agent-status +⚠ HALT ACTIVE — cross-agent comms paused + Reason: investigating runaway poll loop, 2026-04-27 + HALT file: ~/.config/cross-agent-comms/HALT + Resume with: cross-agent-resume + +(snapshot continues normally — HALT does not suppress visibility) + +project pending most-recent +career 0 — +homelab 1 20260427T085611Z-from-career-question.org (3 min ago) +... +``` + +Status is read-only, so it always runs. The banner ensures the user can't +miss that halt is active when checking inbox state. Reason text comes from +the HALT file's body; if empty, omit the reason line. + +If the HALT file exists but is unreadable, print a warning banner ("HALT +file present but unreadable; treat as halted") and continue with normal +output. + +See `cross-agent-halt.md` for the full halt mechanism. + +## Examples + +```bash +# Snapshot +cross-agent-status + +# JSON for piping +cross-agent-status --json | jq '.projects[] | select(.pending_count > 0)' + +# Single-project query +cross-agent-status --projects-glob ~/projects/work/inbox/from-agents/ +``` + +## See also + +- `cross-agent-watch` — push notifications on new arrivals. +- `cross-agent-discover` — enumerate available agents (cross-machine). +- `cross-agent-comms.org` — protocol spec. diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch new file mode 100755 index 0000000..3978f49 --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# cross-agent-watch — desktop-notify on new cross-agent messages. +# +# See cross-agent-watch.md. Watches every ~/projects/*/inbox/from-agents/ by +# default. inotifywait fires create + moved_to events; .tmp.* files are +# filtered out. HALT suppresses notifications but the watcher keeps running +# and logs each event with "(suppressed by HALT)". + +set -uo pipefail + +# Defaults. +PROJECTS_GLOB="${HOME}/projects/*/inbox/from-agents/" +LOG_FILE="${HOME}/.local/state/cross-agent-comms/watch.log" +HALT_FILE="${HOME}/.config/cross-agent-comms/HALT" +QUIET=0 +NO_NOTIFY=0 + +# Arg parsing. +while [[ $# -gt 0 ]]; do + case "$1" in + --projects-glob) + PROJECTS_GLOB="$2"; shift 2 ;; + --log) + LOG_FILE="$2"; shift 2 ;; + --quiet) + QUIET=1; shift ;; + --no-notify) + NO_NOTIFY=1; shift ;; + -h|--help) + cat <<EOF +Usage: cross-agent-watch [--projects-glob GLOB] [--log PATH] [--quiet] [--no-notify] + +Watches inbox/from-agents/ directories for new cross-agent messages and fires +desktop notifications. See cross-agent-watch.md for details. +EOF + exit 0 ;; + *) + echo "unknown flag: $1" >&2; exit 1 ;; + esac +done + +# Resolve glob to a concrete list of directories. +# shellcheck disable=SC2086 +DIRS=( $PROJECTS_GLOB ) +# Filter out non-existent paths (glob may include literal pattern when no match). +EXISTING=() +for d in "${DIRS[@]}"; do + if [[ -d "$d" ]]; then + EXISTING+=( "$d" ) + fi +done + +if [[ ${#EXISTING[@]} -eq 0 ]]; then + echo "cross-agent-watch: glob resolved 0 directories: $PROJECTS_GLOB" >&2 + exit 1 +fi + +# Ensure log dir exists. +mkdir -p "$(dirname "$LOG_FILE")" + +[[ $QUIET -eq 0 ]] && echo "cross-agent-watch: watching ${#EXISTING[@]} dir(s); log: $LOG_FILE" + +# Helper: project name from path like /home/.../projects/<name>/inbox/from-agents/... +project_name() { + local path="$1" + # Match ~/projects/<name>/... + if [[ "$path" =~ ${HOME}/projects/([^/]+)/ ]]; then + echo "${BASH_REMATCH[1]}" + else + basename "$(dirname "$(dirname "$path")")" + fi +} + +# Main loop. inotifywait emits one line per event in the format +# "<full-path>" because we passed --format '%w%f'. +inotifywait -m -e create,moved_to --format '%w%f' "${EXISTING[@]}" 2>/dev/null \ + | while IFS= read -r path; do + filename="$(basename "$path")" + + # Filter .tmp.* staging files. + case "$filename" in + .tmp.*) continue ;; + esac + + # Filter .asc sidecars — they land first per the atomic-write ordering; + # the .org event will fire after. + case "$filename" in + *.asc) continue ;; + esac + + proj="$(project_name "$path")" + iso="$(date -u "+%Y-%m-%dT%H:%M:%SZ")" + + if [[ -e "$HALT_FILE" ]]; then + printf '%s\t%s\t%s\t(suppressed by HALT)\n' "$iso" "$proj" "$filename" >> "$LOG_FILE" + [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename (suppressed by HALT)" + continue + fi + + printf '%s\t%s\t%s\n' "$iso" "$proj" "$filename" >> "$LOG_FILE" + [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename" + + if [[ $NO_NOTIFY -eq 0 ]]; then + notify info "Cross-agent message" "${proj}: ${filename}" 2>/dev/null || true + fi + done diff --git a/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch.md b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch.md new file mode 100644 index 0000000..dd8afc1 --- /dev/null +++ b/claude-templates/.ai/scripts/cross-agent-comms/cross-agent-watch.md @@ -0,0 +1,128 @@ +# cross-agent-watch + +**Purpose.** Long-running watcher that fires desktop notifications when new +cross-agent messages land in any project's `inbox/from-agents/` directory. +This is the primary cold-start mechanism: messages get noticed even when no +Claude session is active. + +## Usage + +``` +cross-agent-watch [--projects-glob <glob>] [--log <path>] +``` + +No args required. Defaults: + +- Watches `~/projects/*/inbox/from-agents/` (matches every project with the + cross-agent-comms convention). +- Logs each event to `~/.local/state/cross-agent-comms/watch.log`. + +### Flags + +| Flag | Default | Purpose | +|---|---|---| +| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to watch. Useful for testing on a single project. | +| `--log <path>` | `~/.local/state/cross-agent-comms/watch.log` | Override log location. Set to `/dev/null` to disable logging. | +| `--quiet` | off | Suppress stdout output. Notifications still fire. | +| `--no-notify` | off | Skip `notify` calls. Useful for testing the watcher loop without spamming notifications. | + +## Behavior + +1. Resolves the projects-glob to a concrete list of directories at startup. + New projects added to `~/projects/` after startup are NOT picked up — restart + the watcher to re-resolve. +2. Runs `inotifywait -m -e create,moved_to --format '%w%f'` against each + watched directory. +3. For each event, calls + `notify info "Cross-agent message" "<project>: <filename>"`. +4. Appends an event line to the log: + `<ISO-8601-timestamp>\t<project>\t<filename>`. + +## Event filtering + +- Watches `create` AND `moved_to` events. The `moved_to` part is critical for + the atomic-write convention (`mktemp` + `mv` produces a `moved_to`, not a + `create`). +- Files starting with `.tmp.` are ignored — they're staging files from + in-progress writes that should never produce a notification. + +## Installation + +### Option A — tmux pane (personal, easy) + +Run in a tmux pane that survives session disconnects: + +``` +tmux new -d -s cross-agent-watch 'cross-agent-watch' +``` + +### Option B — systemd user service (production) + +Provided files: + +- `~/.config/systemd/user/cross-agent-watch.service` +- `~/.config/systemd/user/cross-agent-watch.path` + +Enable with: + +``` +systemctl --user enable --now cross-agent-watch.path +``` + +The path unit triggers the service unit on filesystem changes; the service +unit re-execs `cross-agent-watch` if it dies. Survives reboot. + +## Failure modes + +| Symptom | Likely cause | Fix | +|---|---|---| +| No notifications fire on new files | inotifywait not running, or glob resolved to zero dirs | Check `cross-agent-watch --projects-glob ... --quiet` exits non-zero immediately. Log shows `"resolved 0 directories"`. | +| Notifications fire on `.tmp.` files | Filter regression | Verify `inotifywait` events show the `.tmp.` files; if so check this script's filter logic. | +| Some files missed under rapid bursts | inotify queue overflow | Increase `fs.inotify.max_queued_events` sysctl. Default 16384 is usually fine. | +| Permission denied on a watched dir | Directory perms wrong | `chmod 700 <dir>` and confirm owner. | + +## HALT awareness + +Checks `~/.config/cross-agent-comms/HALT` on each iteration (each inotifywait +event fired). If HALT exists, the watcher continues running but **suppresses +the `notify` call**. The event is still logged, with `(suppressed by HALT)` +appended: + +``` +2026-04-27T04:42:00-05:00 career 20260427T094200Z-from-homelab-test.org (suppressed by HALT) +``` + +Logged-but-suppressed events are useful for the operator to see what would +have fired during the halt window — helpful for diagnosing whatever caused +the halt. + +When HALT clears, suppression stops; subsequent events fire normally. Backlog +events that arrived during halt are NOT replayed — they get picked up via +cold-start handling (status CLI, agent startup check, or the next agent +poll once polling resumes). + +If the HALT file exists but is unreadable, fail-closed (suppress) — safer +than fail-open. + +See `cross-agent-halt.md` for the full halt mechanism. + +## Examples + +```bash +# Watch all projects, log everything, fire notifications +cross-agent-watch + +# Test against a single project, no notifications, verbose +cross-agent-watch \ + --projects-glob "$HOME/projects/work/inbox/from-agents/" \ + --no-notify + +# Production-style: quiet stdout, log only +cross-agent-watch --quiet +``` + +## See also + +- `cross-agent-status` — point-in-time snapshot of pending messages. +- `cross-agent-send` — counterpart writer. +- `cross-agent-comms.org` — protocol spec. diff --git a/claude-templates/.ai/scripts/daily-prep-agenda.el b/claude-templates/.ai/scripts/daily-prep-agenda.el new file mode 100644 index 0000000..4c6041c --- /dev/null +++ b/claude-templates/.ai/scripts/daily-prep-agenda.el @@ -0,0 +1,142 @@ +;;; daily-prep-agenda.el --- Standalone batch agenda extractor for daily-prep +;; +;; Usage: +;; emacs --batch -q -l daily-prep-agenda.el todo.org [pcal.org ...] +;; +;; Filters entries to TODO/DOING/WAITING/NEXT with [#A]/[#B] priority OR +;; DEADLINE/SCHEDULED present. Bucketizes into Overdue, Today, This Week, +;; Priority A (no date), Priority B (no date). Emits heading + body for each. + +(require 'org) +(require 'cl-lib) + +;; Declare the TODO keywords used across Craig's org files so org-mode parses +;; "DOING", "WAITING", "NEXT", "CANCELLED" headings as TODO states. With `-q`, +;; org-mode defaults to just "TODO"/"DONE" and will treat the others as plain +;; heading text (state comes back as nil). +(setq org-todo-keywords + '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED"))) + +(defvar dp-today (format-time-string "%Y-%m-%d")) +(defvar dp-week-end + (format-time-string "%Y-%m-%d" (time-add (current-time) (days-to-time 7)))) + +(defun dp-iso-date (org-ts) + "Extract YYYY-MM-DD from an org timestamp string like '<2026-04-25 Sat 16:00>'." + (when (and org-ts (string-match "\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" org-ts)) + (match-string 1 org-ts))) + +(defun dp-entry-info () + "Return plist of metadata + body for org entry at point." + (let* ((state (org-get-todo-state)) + (el (org-element-at-point)) + (priority (org-element-property :priority el)) + (deadline-raw (org-entry-get (point) "DEADLINE")) + (scheduled-raw (org-entry-get (point) "SCHEDULED")) + (deadline (dp-iso-date deadline-raw)) + (scheduled (dp-iso-date scheduled-raw)) + (heading (org-get-heading t t t t)) + (line (line-number-at-pos)) + (file (or (buffer-file-name) (buffer-name))) + (start (save-excursion (org-end-of-meta-data t) (point))) + (end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point))) + (body (and (< start end) + (string-trim (buffer-substring-no-properties start end))))) + (list :state state + :priority priority + :deadline deadline + :deadline-raw deadline-raw + :scheduled scheduled + :scheduled-raw scheduled-raw + :heading heading + :line line + :file file + :body body))) + +(defun dp-active-candidate-p () + "True if entry at point is an active state with [#A]/[#B] OR has DEADLINE/SCHEDULED." + (let* ((state (org-get-todo-state)) + (el (org-element-at-point)) + (pri (org-element-property :priority el)) + (dl (org-entry-get (point) "DEADLINE")) + (sc (org-entry-get (point) "SCHEDULED"))) + (and (member state '("TODO" "DOING" "WAITING" "NEXT")) + (or (memq pri '(?A ?B)) dl sc)))) + +(defun dp-collect (files) + "Walk FILES, return list of dp-entry-info plists for matching entries." + (let (entries) + (dolist (file files) + (when (file-readable-p file) + (with-current-buffer (find-file-noselect file) + (org-mode) + (org-map-entries + (lambda () + (when (dp-active-candidate-p) + (push (dp-entry-info) entries))) + nil 'file)))) + (nreverse entries))) + +(defun dp-bucket (e) + "Return bucket name for entry plist E." + (let ((dl (plist-get e :deadline)) + (sc (plist-get e :scheduled)) + (pri (plist-get e :priority))) + (cond + ((and dl (string< dl dp-today)) 'overdue) + ((or (equal dl dp-today) (equal sc dp-today)) 'today) + ((and sc (string< sc dp-today)) 'overdue) + ((or (and dl (string< dl dp-week-end)) + (and sc (string< sc dp-week-end))) 'this-week) + ((eq pri ?A) 'pri-a) + ((eq pri ?B) 'pri-b) + (t 'other)))) + +(defun dp-format-entry (e) + "Format entry plist E as org-mode text." + (concat + (format "** %s%s %s\n" + (or (plist-get e :state) "") + (if-let ((p (plist-get e :priority))) (format " [#%c]" p) "") + (plist-get e :heading)) + (format " :LOC: %s:%d\n" + (file-name-nondirectory (plist-get e :file)) + (plist-get e :line)) + (when-let ((d (plist-get e :deadline-raw))) (format " DEADLINE: %s\n" d)) + (when-let ((s (plist-get e :scheduled-raw))) (format " SCHEDULED: %s\n" s)) + (let ((b (plist-get e :body))) + (if (and b (not (string-empty-p b))) + (concat (replace-regexp-in-string "^" " " b) "\n") + "")) + "\n")) + +(defun dp-emit-bucket (label entries) + (when entries + (princ (format "* %s (%d)\n\n" label (length entries))) + (dolist (e entries) + (princ (dp-format-entry e))))) + +;; Main entrypoint +(when noninteractive + (let* ((files command-line-args-left) + (entries (dp-collect files)) + (groups (seq-group-by #'dp-bucket entries))) + (princ (format "# Daily-Prep Extract — %s\n# Files: %s\n# Total candidates: %d\n\n" + dp-today + (mapconcat #'file-name-nondirectory files ", ") + (length entries))) + (dolist (bucket '(overdue today this-week pri-a pri-b other)) + (dp-emit-bucket + (pcase bucket + ('overdue "Overdue") + ('today "Today") + ('this-week "This Week") + ('pri-a "Priority A (undated)") + ('pri-b "Priority B (undated)") + ('other "Other")) + (alist-get bucket groups))))) + +(provide 'daily-prep-agenda) +;;; daily-prep-agenda.el ends here diff --git a/claude-templates/.ai/scripts/eml-view-and-extract-attachments-readme.org b/claude-templates/.ai/scripts/eml-view-and-extract-attachments-readme.org new file mode 100644 index 0000000..3a99d95 --- /dev/null +++ b/claude-templates/.ai/scripts/eml-view-and-extract-attachments-readme.org @@ -0,0 +1,47 @@ +#+TITLE: eml-view-and-extract-attachments.py + +Extract email content and attachments from EML files with auto-renaming. + +* Usage + +#+begin_src bash +# View mode — print metadata and body to stdout, extract attachments alongside EML +python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/message.eml + +# Pipeline mode — extract, auto-rename, refile to output dir, clean up +python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/message.eml --output-dir assets/ +#+end_src + +* Naming Convention + +Files are auto-renamed as =YYYY-MM-DD-HHMM-Sender-TYPE-Description.ext=: + +- =2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.eml= +- =2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.txt= +- =2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf= + +Date and sender are parsed from email headers. Falls back to "unknown" for missing values. + +* Dependencies + +- Python 3 (stdlib only for core functionality) +- =html2text= (optional — used for HTML-only emails, falls back to tag stripping) + +* Pipeline Mode Behavior + +1. Creates a temp directory alongside the source EML +2. Copies and renames the EML, writes a =.txt= of the body, extracts attachments +3. Checks for filename collisions in the output directory +4. Moves all files to the output directory +5. Cleans up the temp directory +6. Prints a summary of created files + +Source EML is never modified or moved. + +* Tests + +#+begin_src bash +python3 -m pytest .ai/scripts/tests/ -v +#+end_src + +48 tests: unit tests for parsing, filename generation, and attachment saving; integration tests for both pipeline and stdout modes. Requires =pytest=. diff --git a/claude-templates/.ai/scripts/eml-view-and-extract-attachments.py b/claude-templates/.ai/scripts/eml-view-and-extract-attachments.py new file mode 100644 index 0000000..dad6457 --- /dev/null +++ b/claude-templates/.ai/scripts/eml-view-and-extract-attachments.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""Extract email content and attachments from EML files. + +Without --output-dir: parse and print to stdout (backwards compatible). +With --output-dir: full pipeline — extract, auto-rename, refile, clean up. +""" + +import argparse +import email +import email.utils +import os +import re +import shutil +import sys +import tempfile + + +# --------------------------------------------------------------------------- +# Parsing functions (no I/O beyond reading the input file) +# --------------------------------------------------------------------------- + +def parse_received_headers(msg): + """Parse Received headers to extract sent/received times and servers.""" + received_headers = msg.get_all('Received', []) + + sent_server = None + sent_time = None + received_server = None + received_time = None + + for header in received_headers: + header = ' '.join(header.split()) + + time_match = re.search(r';\s*(.+)$', header) + timestamp = time_match.group(1).strip() if time_match else None + + from_match = re.search(r'from\s+([\w.-]+)', header) + by_match = re.search(r'by\s+([\w.-]+)', header) + + if from_match and by_match and received_server is None: + received_time = timestamp + received_server = by_match.group(1) + sent_server = from_match.group(1) + sent_time = timestamp + + if received_server is None and received_headers: + header = ' '.join(received_headers[0].split()) + time_match = re.search(r';\s*(.+)$', header) + received_time = time_match.group(1).strip() if time_match else None + by_match = re.search(r'by\s+([\w.-]+)', header) + received_server = by_match.group(1) if by_match else "unknown" + + return { + 'sent_time': sent_time, + 'sent_server': sent_server, + 'received_time': received_time, + 'received_server': received_server + } + + +def extract_body(msg): + """Walk MIME parts, prefer text/plain, fall back to html2text on text/html. + + Returns body text string. + """ + plain_text = None + html_text = None + + for part in msg.walk(): + content_type = part.get_content_type() + if content_type == "text/plain" and plain_text is None: + payload = part.get_payload(decode=True) + if payload is not None: + plain_text = payload.decode('utf-8', errors='ignore') + elif content_type == "text/html" and html_text is None: + payload = part.get_payload(decode=True) + if payload is not None: + html_text = payload.decode('utf-8', errors='ignore') + + if plain_text is not None: + return plain_text + + if html_text is not None: + try: + import html2text + h = html2text.HTML2Text() + h.body_width = 0 + return h.handle(html_text) + except ImportError: + # Strip HTML tags as fallback if html2text not installed + return re.sub(r'<[^>]+>', '', html_text) + + return "" + + +def extract_metadata(msg): + """Extract email metadata from headers. + + Returns dict with from, to, subject, date, and timing info. + """ + return { + 'from': msg.get('From'), + 'to': msg.get('To'), + 'subject': msg.get('Subject'), + 'date': msg.get('Date'), + 'timing': parse_received_headers(msg), + } + + +def generate_basename(metadata): + """Generate date-sender prefix from metadata. + + Returns e.g. "2026-02-05-1136-Jonathan". + Falls back to "unknown" for missing/malformed Date or From. + """ + # Parse date + date_str = metadata.get('date') + date_prefix = "unknown" + if date_str: + try: + parsed = email.utils.parsedate_to_datetime(date_str) + date_prefix = parsed.strftime('%Y-%m-%d-%H%M') + except (ValueError, TypeError): + pass + + # Parse sender first name + from_str = metadata.get('from') + sender = "unknown" + if from_str: + # Extract display name or email local part + display_name, addr = email.utils.parseaddr(from_str) + if display_name: + sender = display_name.split()[0] + elif addr: + sender = addr.split('@')[0] + + return f"{date_prefix}-{sender}" + + +def _clean_for_filename(text, max_length=80): + """Clean text for use in a filename. + + Replace spaces with hyphens, strip chars unsafe for filenames, + collapse multiple hyphens. + """ + text = text.strip() + text = text.replace(' ', '-') + # Keep alphanumeric, hyphens, dots, underscores + text = re.sub(r'[^\w\-.]', '', text) + # Collapse multiple hyphens + text = re.sub(r'-{2,}', '-', text) + # Strip leading/trailing hyphens + text = text.strip('-') + if len(text) > max_length: + text = text[:max_length].rstrip('-') + return text + + +def generate_email_filename(basename, subject): + """Generate email filename from basename and subject. + + Returns e.g. "2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street" + (without extension — caller adds .eml or .txt). + """ + if subject: + clean_subject = _clean_for_filename(subject) + else: + clean_subject = "no-subject" + return f"{basename}-EMAIL-{clean_subject}" + + +def generate_attachment_filename(basename, original_filename): + """Generate attachment filename from basename and original filename. + + Returns e.g. "2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf". + Preserves original extension. + """ + if not original_filename: + return f"{basename}-ATTACH-unnamed" + + name, ext = os.path.splitext(original_filename) + clean_name = _clean_for_filename(name) + return f"{basename}-ATTACH-{clean_name}{ext}" + + +# --------------------------------------------------------------------------- +# I/O functions (file operations) +# --------------------------------------------------------------------------- + +def save_attachments(msg, output_dir, basename): + """Write attachment files to output_dir with auto-renamed filenames. + + Returns list of dicts: {original_name, renamed_name, path}. + """ + results = [] + used_names = set() + for part in msg.walk(): + if part.get_content_maintype() == 'multipart': + continue + if part.get('Content-Disposition') is None: + continue + + filename = part.get_filename() + if filename: + # Outlook inlines the same signature image many times under one + # filename. Disambiguate so each part gets its own file rather + # than overwriting earlier ones in temp_dir. + renamed = generate_attachment_filename(basename, filename) + if renamed in used_names: + stem, ext = os.path.splitext(renamed) + n = 2 + while f"{stem}-{n}{ext}" in used_names: + n += 1 + renamed = f"{stem}-{n}{ext}" + used_names.add(renamed) + + filepath = os.path.join(output_dir, renamed) + with open(filepath, 'wb') as f: + f.write(part.get_payload(decode=True)) + results.append({ + 'original_name': filename, + 'renamed_name': renamed, + 'path': filepath, + }) + + return results + + +def save_text(text, filepath): + """Write body text to a .txt file.""" + with open(filepath, 'w', encoding='utf-8') as f: + f.write(text) + + +# --------------------------------------------------------------------------- +# Pipeline function +# --------------------------------------------------------------------------- + +def process_eml(eml_path, output_dir): + """Full extraction pipeline. + + 1. Create temp extraction dir + 2. Copy EML into temp dir + 3. Parse email (metadata, body, attachments) + 4. Generate filenames from headers + 5. Save renamed .eml, .txt, and attachments to temp dir + 6. Check for collisions in output_dir + 7. Move all files to output_dir + 8. Clean up temp dir + 9. Return results dict + """ + eml_path = os.path.abspath(eml_path) + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + + # Create temp dir as sibling of the EML file + eml_dir = os.path.dirname(eml_path) + temp_dir = tempfile.mkdtemp(prefix='extract-', dir=eml_dir) + + try: + # Copy EML to temp dir + temp_eml = os.path.join(temp_dir, os.path.basename(eml_path)) + shutil.copy2(eml_path, temp_eml) + + # Parse + with open(eml_path, 'rb') as f: + msg = email.message_from_binary_file(f) + + metadata = extract_metadata(msg) + body = extract_body(msg) + basename = generate_basename(metadata) + email_stem = generate_email_filename(basename, metadata['subject']) + + # Save renamed EML + renamed_eml = f"{email_stem}.eml" + renamed_eml_path = os.path.join(temp_dir, renamed_eml) + os.rename(temp_eml, renamed_eml_path) + + # Save .txt + renamed_txt = f"{email_stem}.txt" + renamed_txt_path = os.path.join(temp_dir, renamed_txt) + save_text(body, renamed_txt_path) + + # Save attachments + attachment_results = save_attachments(msg, temp_dir, basename) + + # Build file list + files = [ + {'type': 'eml', 'name': renamed_eml, 'path': None}, + {'type': 'txt', 'name': renamed_txt, 'path': None}, + ] + for att in attachment_results: + files.append({ + 'type': 'attach', + 'name': att['renamed_name'], + 'path': None, + }) + + # Check for collisions in output_dir + for file_info in files: + dest = os.path.join(output_dir, file_info['name']) + if os.path.exists(dest): + raise FileExistsError( + f"Collision: '{file_info['name']}' already exists in {output_dir}" + ) + + # Move all files to output_dir + for file_info in files: + src = os.path.join(temp_dir, file_info['name']) + dest = os.path.join(output_dir, file_info['name']) + shutil.move(src, dest) + file_info['path'] = dest + + return { + 'metadata': metadata, + 'body': body, + 'files': files, + } + + finally: + # Clean up temp dir + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + + +# --------------------------------------------------------------------------- +# Stdout display (backwards-compatible mode) +# --------------------------------------------------------------------------- + +def print_email(eml_path): + """Parse and print email to stdout. Extract attachments alongside EML. + + This preserves the original script behavior when --output-dir is not given. + """ + with open(eml_path, 'rb') as f: + msg = email.message_from_binary_file(f) + + metadata = extract_metadata(msg) + body = extract_body(msg) + timing = metadata['timing'] + + print(f"From: {metadata['from']}") + print(f"To: {metadata['to']}") + print(f"Subject: {metadata['subject']}") + print(f"Date: {metadata['date']}") + print(f"Sent: {timing['sent_time']} (via {timing['sent_server']})") + print(f"Received: {timing['received_time']} (at {timing['received_server']})") + print() + print(body) + print() + + # Extract attachments alongside the EML file + for part in msg.walk(): + if part.get_content_maintype() == 'multipart': + continue + if part.get('Content-Disposition') is None: + continue + + filename = part.get_filename() + if filename: + filepath = os.path.join(os.path.dirname(eml_path), filename) + with open(filepath, 'wb') as f: + f.write(part.get_payload(decode=True)) + print(f"Extracted attachment: {filename}") + + +def print_pipeline_summary(result): + """Print summary after pipeline extraction.""" + metadata = result['metadata'] + timing = metadata['timing'] + + print(f"From: {metadata['from']}") + print(f"To: {metadata['to']}") + print(f"Subject: {metadata['subject']}") + print(f"Date: {metadata['date']}") + print(f"Sent: {timing['sent_time']} (via {timing['sent_server']})") + print(f"Received: {timing['received_time']} (at {timing['received_server']})") + print() + print("Files created:") + for f in result['files']: + print(f" [{f['type']:>6}] {f['name']}") + print(f"\nOutput directory: {os.path.dirname(result['files'][0]['path'])}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Extract email content and attachments from EML files." + ) + parser.add_argument('eml_path', help="Path to source EML file") + parser.add_argument( + '--output-dir', + help="Destination directory for extracted files. " + "Without this flag, prints to stdout only (backwards compatible)." + ) + + args = parser.parse_args() + + if not os.path.isfile(args.eml_path): + print(f"Error: '{args.eml_path}' not found or is not a file.", file=sys.stderr) + sys.exit(1) + + if args.output_dir: + result = process_eml(args.eml_path, args.output_dir) + print_pipeline_summary(result) + else: + print_email(args.eml_path) diff --git a/claude-templates/.ai/scripts/gmail-fetch-attachments.py b/claude-templates/.ai/scripts/gmail-fetch-attachments.py new file mode 100755 index 0000000..b42101c --- /dev/null +++ b/claude-templates/.ai/scripts/gmail-fetch-attachments.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Fetch Gmail message attachments via the same OAuth identity the +google-docs-mcp servers use. + +Usage: + gmail-fetch-attachments.py --profile {personal,work} \ + --message-id <ID> --output-dir <PATH> + +Reuses: + - Refresh token at ~/.config/google-docs-mcp/[<GOOGLE_MCP_PROFILE>/]token.json + (the subdir is only present when GOOGLE_MCP_PROFILE is set on the + mcpServers entry; otherwise the cache lives at the directory root) + - Client ID + secret from ~/.claude.json's + mcpServers["google-docs-<profile>"].env + +Stdlib only. Saves each non-inline attachment using its original filename. +Skips attachments that already exist in --output-dir (size-matched). +""" +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" +GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me" +TOKEN_DIR = Path.home() / ".config" / "google-docs-mcp" +CLAUDE_CONFIG = Path.home() / ".claude.json" + + +def load_mcp_env(profile: str) -> dict: + if not CLAUDE_CONFIG.exists(): + sys.exit(f"claude config missing: {CLAUDE_CONFIG}") + config = json.loads(CLAUDE_CONFIG.read_text()) + server_name = f"google-docs-{profile}" + servers = config.get("mcpServers", {}) + if server_name not in servers: + sys.exit(f"mcpServers.{server_name} not found in {CLAUDE_CONFIG}") + return servers[server_name].get("env", {}) or {} + + +def load_refresh_token(env: dict) -> str: + # The MCP server keys its token cache by GOOGLE_MCP_PROFILE. When the + # var is unset on the mcpServers entry, the cache lives at the root + # (TOKEN_DIR/token.json), not under a <profile>/ subdirectory. + mcp_profile = env.get("GOOGLE_MCP_PROFILE") or "" + path = TOKEN_DIR / mcp_profile / "token.json" if mcp_profile else TOKEN_DIR / "token.json" + if not path.exists(): + sys.exit(f"token cache missing: {path}") + data = json.loads(path.read_text()) + if "refresh_token" not in data: + sys.exit(f"no refresh_token in {path}") + return data["refresh_token"] + + +def load_client_creds(env: dict) -> tuple[str, str]: + cid = env.get("GOOGLE_CLIENT_ID") + secret = env.get("GOOGLE_CLIENT_SECRET") + if not cid or not secret: + sys.exit("GOOGLE_CLIENT_ID/SECRET missing in MCP env") + return cid, secret + + +def refresh_access_token(refresh_token: str, client_id: str, client_secret: str) -> str: + body = urllib.parse.urlencode( + { + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + ).encode() + req = urllib.request.Request( + OAUTH_TOKEN_URL, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read()) + if "access_token" not in payload: + sys.exit(f"refresh failed: {payload}") + return payload["access_token"] + + +def gmail_get(path: str, access_token: str) -> dict: + req = urllib.request.Request( + f"{GMAIL_API}{path}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read()) + + +def collect_attachments(payload: dict) -> list[dict]: + """Walk the MIME tree and collect parts that have an attachmentId. + + Returns list of {filename, attachmentId, size, mimeType}. + Skips parts without a filename (inline images, etc.). + """ + results: list[dict] = [] + + def walk(part: dict) -> None: + body = part.get("body", {}) or {} + filename = part.get("filename") or "" + if filename and "attachmentId" in body: + results.append( + { + "filename": filename, + "attachmentId": body["attachmentId"], + "size": body.get("size", 0), + "mimeType": part.get("mimeType", "application/octet-stream"), + } + ) + for sub in part.get("parts", []) or []: + walk(sub) + + walk(payload) + return results + + +def safe_filename(name: str) -> str: + """Strip path separators and leading parent-dir markers (..). + + Path separators become underscores so the filename can't escape the + output directory. Leading ".." sequences are stripped so an attachment + named "../foo" lands as "_foo" rather than ".._foo". Single leading + dots are preserved so dotfiles like ".gitignore" survive intact. + """ + cleaned = name.replace("/", "_").replace("\\", "_") + while cleaned.startswith(".."): + cleaned = cleaned[2:] + return cleaned + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--profile", choices=["personal", "work"], required=True) + ap.add_argument("--message-id", required=True) + ap.add_argument("--output-dir", required=True, type=Path) + args = ap.parse_args() + + args.output_dir.mkdir(parents=True, exist_ok=True) + + env = load_mcp_env(args.profile) + refresh_token = load_refresh_token(env) + client_id, client_secret = load_client_creds(env) + access_token = refresh_access_token(refresh_token, client_id, client_secret) + + msg = gmail_get( + f"/messages/{args.message_id}?format=full", access_token + ) + attachments = collect_attachments(msg.get("payload", {})) + + if not attachments: + print("no attachments on this message") + return 0 + + print(f"found {len(attachments)} attachment(s):") + for att in attachments: + target = args.output_dir / safe_filename(att["filename"]) + if target.exists() and target.stat().st_size == att["size"]: + print(f" skip (already present): {target}") + continue + data_resp = gmail_get( + f"/messages/{args.message_id}/attachments/{att['attachmentId']}", + access_token, + ) + raw = base64.urlsafe_b64decode(data_resp["data"]) + target.write_bytes(raw) + print(f" saved: {target} ({len(raw):,} bytes)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py new file mode 100644 index 0000000..8e650ff --- /dev/null +++ b/claude-templates/.ai/scripts/inbox-send.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""inbox-send — send text or a file to another project's top-level inbox/. + +Universal cross-project inbox messaging tool. A "project" here is a +directory that contains both a `.ai/` marker (signalling it's a +Claude-managed project) and a top-level `inbox/` directory (Craig's +inbox convention). The script lets you drop a text message or copy a +file into a target project's `inbox/`, with a dated filename that +records the source project so the target's next session picks it up +cleanly. + +Usage: + inbox-send --list + inbox-send <target> --text "your message" [--name custom-slug] + inbox-send <target> --file <path> [--name custom-slug] + +<target> is the project's basename (or the numeric index from --list). + +Discovery roots default to ~/projects/ and ~/code/ (parent dirs whose +children are scanned). Override with INBOX_SEND_ROOTS (colon-separated +paths) or write paths into ~/.claude/inbox-roots.txt, one per line. +A root may be either a parent directory or a specific project root +(e.g. ~/.emacs.d); if the root itself is a project, it's included +directly. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import sys +from datetime import datetime +from pathlib import Path + +DEFAULT_ROOTS = [Path.home() / "projects", Path.home() / "code"] +MAX_SLUG_LENGTH = 40 +TS_FILENAME_FMT = "%Y-%m-%d-%H%M" +TS_DOC_FMT = "%Y-%m-%d %H:%M:%S %z" + + +def resolve_roots() -> list[Path]: + """Resolve discovery roots: env var → config file → defaults.""" + env_roots = os.environ.get("INBOX_SEND_ROOTS") + if env_roots: + return [Path(p) for p in env_roots.split(":") if p] + config = Path.home() / ".claude" / "inbox-roots.txt" + if config.is_file(): + paths: list[Path] = [] + for line in config.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#"): + paths.append(Path(line).expanduser()) + if paths: + return paths + return DEFAULT_ROOTS + + +def _is_project(path: Path) -> bool: + """A project has a `.ai/` marker AND a top-level `inbox/` directory.""" + return (path / ".ai").is_dir() and (path / "inbox").is_dir() + + +def discover_projects(roots: list[Path]) -> list[Path]: + """Return absolute paths of projects discovered under `roots`. + + A root may be either a parent directory (its children are scanned) or + a specific project root (included directly if it qualifies). + """ + projects: list[Path] = [] + for root in roots: + if not root.is_dir(): + continue + if _is_project(root): + projects.append(root) + continue + for child in sorted(root.iterdir()): + if not child.is_dir(): + continue + if _is_project(child): + projects.append(child) + return projects + + +def find_current_project(start: Path) -> Path | None: + """Walk up from `start` looking for the nearest dir containing .ai/.""" + cur = start.resolve() + while cur != cur.parent: + if (cur / ".ai").is_dir(): + return cur + cur = cur.parent + return None + + +def slugify(text: str, max_length: int = MAX_SLUG_LENGTH) -> str: + """Turn freeform text into a filename-safe slug.""" + text = text.lower() + text = re.sub(r"[^a-z0-9\s]+", " ", text) + text = re.sub(r"\s+", " ", text).strip() + if not text: + return "" + if len(text) <= max_length: + return text.replace(" ", "-") + # Truncate, then walk back to the last whitespace to keep a word boundary. + truncated = text[:max_length] + last_space = truncated.rfind(" ") + if last_space > 0: + truncated = truncated[:last_space] + return truncated.strip().replace(" ", "-") + + +def find_target(target_name: str, projects: list[Path]) -> Path | None: + """Resolve `target_name` against the project list (basename or numeric index).""" + if target_name.isdigit(): + idx = int(target_name) - 1 + if 0 <= idx < len(projects): + return projects[idx] + return None + for p in projects: + if p.name == target_name: + return p + return None + + +def build_text_org(message: str, source_name: str, timestamp: str) -> str: + """Wrap a text message in a minimal org-mode skeleton.""" + title = message.strip().splitlines()[0][:60] if message.strip() else "(empty)" + return ( + f"#+TITLE: {title}\n" + f"#+SOURCE: from {source_name}\n" + f"#+DATE: {timestamp}\n\n" + f"{message.rstrip()}\n" + ) + + +def send_text( + target_inbox: Path, + message: str, + source_name: str, + custom_name: str | None, + now: datetime, +) -> Path: + """Write a text message into target_inbox as a dated .org file.""" + if not message.strip(): + raise ValueError("--text cannot be empty or whitespace-only") + slug = custom_name or slugify(message) + if not slug: + raise ValueError(f"could not derive a slug from text: {message!r}") + filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" + dest = target_inbox / filename + dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) + return dest + + +def send_file( + target_inbox: Path, + src_path: Path, + source_name: str, + custom_name: str | None, + now: datetime, +) -> Path: + """Copy src_path into target_inbox with a dated, source-tagged name.""" + if not src_path.is_file(): + raise FileNotFoundError(f"source file not found: {src_path}") + slug = custom_name or slugify(src_path.stem) + if not slug: + raise ValueError(f"could not derive a slug from file: {src_path}") + ext = src_path.suffix + filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" + dest = target_inbox / filename + shutil.copy2(src_path, dest) + return dest + + +def print_project_list(projects: list[Path], current: Path | None) -> None: + """Print numbered list of projects, with the current one excluded.""" + others = [p for p in projects if current is None or p.resolve() != current.resolve()] + if not others: + print("No projects (.ai/ + inbox/) found under the configured roots.") + return + print(f"Available .ai projects ({len(others)}):") + width = max(len(p.name) for p in others) + for i, p in enumerate(others, 1): + print(f" {i}. {p.name:<{width}} {p}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Send text or a file to another .ai project's inbox/.", + ) + parser.add_argument( + "--list", action="store_true", + help="List available .ai projects and exit.", + ) + parser.add_argument( + "target", nargs="?", + help="Target project basename or numeric index from --list.", + ) + parser.add_argument( + "--text", + help="Text message to drop as an .org file in the target inbox.", + ) + parser.add_argument( + "--file", type=Path, + help="Path to a file to copy into the target inbox.", + ) + parser.add_argument( + "--name", + help="Override the auto-derived filename slug.", + ) + args = parser.parse_args() + + roots = resolve_roots() + projects = discover_projects(roots) + current = find_current_project(Path.cwd()) + + if args.list: + print_project_list(projects, current) + return 0 + + if not args.target: + parser.error("must provide a target project (or --list)") + if args.text is None and args.file is None: + parser.error("must provide --text or --file") + if args.text is not None and args.file is not None: + parser.error("--text and --file are mutually exclusive") + + others = [p for p in projects if current is None or p.resolve() != current.resolve()] + target = find_target(args.target, others) + if target is None: + print(f"inbox-send: unknown target {args.target!r}.", file=sys.stderr) + print("Run `inbox-send --list` to see available projects.", file=sys.stderr) + return 1 + + target_inbox = target / "inbox" + if not target_inbox.is_dir(): + print( + f"inbox-send: target {target.name!r} has no top-level inbox/ directory.", + file=sys.stderr, + ) + return 1 + + source_name = current.name if current else Path.cwd().name + now = datetime.now().astimezone() + + try: + if args.text is not None: + dest = send_text(target_inbox, args.text, source_name, args.name, now) + else: + assert args.file is not None + dest = send_file(target_inbox, args.file, source_name, args.name, now) + except (ValueError, FileNotFoundError) as exc: + print(f"inbox-send: {exc}", file=sys.stderr) + return 1 + + print(f"Sent: {dest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el new file mode 100644 index 0000000..3e643d4 --- /dev/null +++ b/claude-templates/.ai/scripts/lint-org.el @@ -0,0 +1,365 @@ +;;; lint-org.el --- org-lint sweeper for tracked org files -*- lexical-binding: t; -*- +;; +;; Usage: +;; emacs --batch -q -l lint-org.el FILE.org [FILE.org ...] +;; apply mechanical fixes in place, emit judgment items on stdout for the +;; command layer to walk +;; +;; emacs --batch -q -l lint-org.el --check FILE.org [FILE.org ...] +;; report only — categorize without modifying the file +;; +;; emacs --batch -q -l lint-org.el --followups-file=PATH FILE.org +;; apply mechanical fixes; if any judgment items remain, append them to +;; PATH as an org section dated today. Used by wrap-it-up to defer the +;; judgment walk to the next morning's review without blocking the wrap. +;; +;; Mechanical categories (auto-fixed): +;; item-number add [@N] directive to drifted bullets +;; missing-language-in-src-block convert bare #+begin_src to #+begin_example +;; misplaced-planning-info merge multi-line CLOSED:/DEADLINE:/SCHEDULED: +;; misplaced-heading (markdown-bold) **X.** at start of line → *X.* +;; +;; Judgment categories (emitted on stdout): +;; misplaced-heading (verbatim-*) =*** Foo= inside body prose +;; link-to-local-file broken file: links +;; invalid-fuzzy-link broken *Heading refs +;; suspicious-language-in-src-block unknown source-block language +;; (anything else) surfaced as judgment with checker name +;; +;; Output format on stdout: +;; first line: ;; lint-org: file=<path> mechanical=<N>[ (would-fix)] judgment=<M> +;; each issue: (:kind mechanical-fixed|judgment :line <N> :checker <symbol> :msg "..." [:preview t]) +;; +;; Before modifying a file, a backup is copied to +;; /tmp/<basename>.before-lint-pass.<YYYYMMDD-HHMMSS> + +(require 'org) +(require 'org-lint) +(require 'cl-lib) +(require 'subr-x) + +(defvar lo-fixes 0 + "Count of mechanical fixes applied (or would-apply in --check) on the last file.") +(defvar lo-issues nil + "Reverse-document-order list of plists describing each issue from the last file. +Each plist has :kind (mechanical-fixed | judgment), :line, :checker, :msg. +Mechanical entries from --check mode also carry :preview t.") +(defvar lo-check-only nil + "Non-nil means run in report-only mode — no buffer writes.") +(defvar lo-current-file nil + "Path of the file currently being processed.") +(defvar lo-followups-file nil + "When non-nil, after a non-check run any judgment items are appended to this +path as an org section dated today. The file is created if missing.") + +(defconst lo-mechanical-checkers + '(item-number missing-language-in-src-block misplaced-planning-info) + "org-lint checker names that are always treated as mechanical.") + +;; misplaced-heading is split case-by-case (markdown-bold vs verbatim-asterisk) +;; in `lo--handle-item'. + +;;; --------------------------------------------------------------------------- +;;; org-lint result accessors + +(defun lo--checker-name (item) + "Return the checker symbol name for ITEM." + (let* ((vec (cadr item)) + (checker (aref vec 3))) + (org-lint-checker-name checker))) + +(defun lo--line (item) + "Return the 1-based line number for ITEM." + (let* ((vec (cadr item)) + (marker-str (aref vec 0))) + (string-to-number (substring-no-properties marker-str)))) + +(defun lo--message (item) + "Return the human-readable message for ITEM." + (let ((vec (cadr item))) (aref vec 2))) + +;;; --------------------------------------------------------------------------- +;;; Mechanical fixers — each runs against the current buffer, returns +;;; non-nil on success, nil if its preconditions don't hold (already +;;; fixed, unexpected shape, etc.). + +(defun lo--goto-line (line) + (goto-char (point-min)) + (forward-line (1- line))) + +(defun lo-fix-item-number (line) + "Insert an [@N] counter on the bullet at LINE, derived from its leading number." + (save-excursion + (lo--goto-line line) + (when (looking-at "^[ \t]*\\([0-9]+\\)[.)]\\([ \t]+\\)") + (let ((num (match-string 1))) + (goto-char (match-end 0)) + (unless (looking-at "\\[@") + (insert (format "[@%s] " num)) + t))))) + +(defun lo-fix-missing-language (line) + "Convert a bare `#+begin_src` block starting at LINE to `#+begin_example`. +Locates the matching `#+end_src` directly below and rewrites it too." + (save-excursion + (lo--goto-line line) + (when (looking-at "^\\([ \t]*\\)#\\+begin_src[ \t]*$") + (let* ((indent (match-string 1)) + (begin-bol (line-beginning-position)) + (begin-eol (line-end-position)) + ;; case-fold the end keyword search to match org's tolerance + (end-re (format "^%s#\\+end_src[ \t]*$" (regexp-quote indent)))) + (delete-region begin-bol begin-eol) + (insert (format "%s#+begin_example" indent)) + (forward-line 1) + (when (re-search-forward end-re nil t) + (replace-match (format "%s#+end_example" indent) t t) + t))))) + +(defun lo-fix-misplaced-planning (line) + "Collapse all planning lines under the heading containing LINE into a single +canonical line right after the heading, ordered CLOSED → DEADLINE → SCHEDULED. +LINE positions the search start — the fixer then rebuilds the whole entry's +planning block at once, so it does the right thing whether the misplaced line +is the first, last, or middle of the run." + (save-excursion + (lo--goto-line line) + (when (re-search-backward "^\\*+ " nil t) + (let* ((heading-bol (line-beginning-position)) + (body-start (progn (forward-line 1) (point))) + (entry-end (save-excursion (outline-next-heading) (point))) + (parts nil) + (ranges nil)) + (goto-char body-start) + (while (re-search-forward + "^[ \t]*\\(CLOSED\\|DEADLINE\\|SCHEDULED\\):.*$" + entry-end t) + (let* ((line-bol (match-beginning 0)) + (line-eol (match-end 0)) + (content (buffer-substring-no-properties line-bol line-eol)) + (pos 0)) + (while (string-match + "\\(CLOSED\\|DEADLINE\\|SCHEDULED\\):[ \t]*\\(\\[[^]]+\\]\\|<[^>]+>\\)" + content pos) + (push (cons (match-string 1 content) + (match-string 2 content)) + parts) + (setq pos (match-end 0))) + ;; Record line-bol .. line-eol+1 so the trailing newline goes too. + (push (cons line-bol (min (1+ line-eol) (point-max))) ranges))) + (when (> (length parts) 1) + (let* ((order '("CLOSED" "DEADLINE" "SCHEDULED")) + (deduped (cl-remove-duplicates (nreverse parts) :test #'equal)) + (sorted (sort deduped + (lambda (a b) + (< (or (cl-position (car a) order :test #'string=) 99) + (or (cl-position (car b) order :test #'string=) 99))))) + (merged (mapconcat (lambda (p) (format "%s: %s" (car p) (cdr p))) + sorted " "))) + (dolist (r (sort (copy-sequence ranges) + (lambda (a b) (> (car a) (car b))))) + (delete-region (car r) (cdr r))) + (goto-char heading-bol) + (forward-line 1) + (insert merged "\n") + t)))))) + +(defun lo--find-markdown-bold-line (reported-line) + "Return the actual line number containing a leading `**X**` near REPORTED-LINE. +org-lint's marker for misplaced-heading typically points at the blank line +following the offender, so check (REPORTED-LINE - 1) before REPORTED-LINE. +Returns nil if no nearby line matches the markdown-bold pattern." + (save-excursion + (cl-loop for candidate in (list (1- reported-line) reported-line) + when (and (>= candidate 1) + (progn (lo--goto-line candidate) + (looking-at "^\\*\\*[^*\n]+\\*\\*"))) + return candidate))) + +(defun lo--markdown-bold-at-line-p (line) + "Non-nil if LINE (or LINE - 1) looks like a markdown-bold case of +misplaced-heading. Pattern: `**X**` at the start of the line, X a short prose +run without asterisks." + (and (lo--find-markdown-bold-line line) t)) + +(defun lo-fix-markdown-bold (line) + "Convert a leading `**X**` near LINE to `*X*` (org single-asterisk bold). +Uses `lo--find-markdown-bold-line' to locate the actual offender, since +org-lint reports the blank line after the heading-like text." + (let ((actual (lo--find-markdown-bold-line line))) + (when actual + (save-excursion + (lo--goto-line actual) + (when (looking-at "^\\(\\*\\*\\)\\([^*\n]+\\)\\(\\*\\*\\)") + (let ((start (match-beginning 0)) + (end (match-end 0)) + (inner (match-string 2))) + (delete-region start end) + (goto-char start) + (insert (format "*%s*" inner)) + t)))))) + +;;; --------------------------------------------------------------------------- +;;; Per-item dispatch + +(defun lo--emit-judgment (name line msg) + (push (list :kind 'judgment :line line :checker name :msg msg) + lo-issues)) + +(defun lo--apply-or-preview (name line msg fixer) + (cond + (lo-check-only + (cl-incf lo-fixes) + (push (list :kind 'mechanical-fixed :line line :checker name :msg msg + :preview t) + lo-issues)) + ((funcall fixer line) + (cl-incf lo-fixes) + (push (list :kind 'mechanical-fixed :line line :checker name :msg msg) + lo-issues)) + (t + ;; Fixer declined — emit as judgment so nothing is silently swallowed. + (lo--emit-judgment name line msg)))) + +(defun lo--handle-item (item) + (let ((name (lo--checker-name item)) + (line (lo--line item)) + (msg (lo--message item))) + (cond + ((eq name 'item-number) + (lo--apply-or-preview name line msg #'lo-fix-item-number)) + ((eq name 'missing-language-in-src-block) + (lo--apply-or-preview name line msg #'lo-fix-missing-language)) + ((eq name 'misplaced-planning-info) + (lo--apply-or-preview name line msg #'lo-fix-misplaced-planning)) + ((eq name 'misplaced-heading) + (if (lo--markdown-bold-at-line-p line) + (lo--apply-or-preview name line msg #'lo-fix-markdown-bold) + (lo--emit-judgment name line msg))) + (t + (lo--emit-judgment name line msg))))) + +;;; --------------------------------------------------------------------------- +;;; File processing + +(defun lo--backup (file) + "Copy FILE to /tmp before any modification. Skipped in --check mode." + (let ((backup (format "/tmp/%s.before-lint-pass.%s" + (file-name-nondirectory file) + (format-time-string "%Y%m%d-%H%M%S")))) + (copy-file file backup t) + backup)) + +(defun lo-process-file (file) + "Run org-lint against FILE, apply mechanical fixes, collect judgment items. +Resets `lo-fixes' and `lo-issues' for each call. In --check mode the file is +left unmodified and mechanical entries are recorded with :preview t." + (setq lo-current-file file lo-fixes 0 lo-issues nil) + (unless lo-check-only + (lo--backup file)) + (let ((buf (find-file-noselect file))) + (unwind-protect + (with-current-buffer buf + (revert-buffer t t t) + (let* ((report (org-lint)) + ;; Descending line order: applying a fix that adds/removes + ;; lines doesn't perturb the line numbers of items at smaller + ;; line numbers that haven't been processed yet. + (sorted (sort (copy-sequence report) + (lambda (a b) (> (lo--line a) (lo--line b)))))) + (dolist (item sorted) + (lo--handle-item item))) + (when (and (not lo-check-only) (buffer-modified-p)) + (save-buffer))) + (with-current-buffer buf (set-buffer-modified-p nil)) + (kill-buffer buf)))) + +;;; --------------------------------------------------------------------------- +;;; Reporting + +(defun lo--append-followups () + "Append any judgment items from the current run to `lo-followups-file' as a +dated org section. No-op when the file path is unset or there are no +judgment items." + (when lo-followups-file + (let ((judgments (cl-remove-if-not + (lambda (i) (eq (plist-get i :kind) 'judgment)) + (reverse lo-issues)))) + (when judgments + (let ((dir (file-name-directory (expand-file-name lo-followups-file)))) + (when dir (make-directory dir t))) + (with-temp-buffer + (insert (format "\n* %s lint-org follow-ups — %s\n" + (format-time-string "%Y-%m-%d") + (file-name-nondirectory lo-current-file))) + (dolist (i judgments) + (insert (format "** TODO line %d — %s — %s\n" + (plist-get i :line) + (plist-get i :checker) + (plist-get i :msg)))) + (append-to-file (point-min) (point-max) lo-followups-file)))))) + +(defun lo-emit-report () + "Print the per-file summary line plus each issue as a readable plist. +After printing, also append judgments to `lo-followups-file' when set." + (let ((mech (cl-count-if (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed)) + lo-issues)) + (judg (cl-count-if (lambda (i) (eq (plist-get i :kind) 'judgment)) + lo-issues))) + (princ (format ";; lint-org: file=%s mechanical=%d%s judgment=%d%s\n" + lo-current-file mech + (if lo-check-only " (would-fix)" "") + judg + (if (and lo-followups-file (> judg 0)) + (format " followups=%s" lo-followups-file) + ""))) + (dolist (i (reverse lo-issues)) + (princ (format "%S\n" i))) + (unless lo-check-only + (lo--append-followups)))) + +;;; --------------------------------------------------------------------------- +;;; CLI + +(defun lo-main () + (when (member "--check" command-line-args-left) + (setq lo-check-only t) + (setq command-line-args-left (delete "--check" command-line-args-left))) + (let ((followups (cl-find-if + (lambda (a) (string-prefix-p "--followups-file=" a)) + command-line-args-left))) + (when followups + (setq lo-followups-file (substring followups (length "--followups-file="))) + (setq command-line-args-left (delete followups command-line-args-left)))) + (if (null command-line-args-left) + (progn + (princ "Usage: emacs --batch -q -l lint-org.el [--check] [--followups-file=PATH] FILE.org ...\n") + (kill-emacs 1)) + (let ((files command-line-args-left)) + (setq command-line-args-left nil) + (dolist (file files) + (if (file-readable-p file) + (progn + (lo-process-file file) + (lo-emit-report)) + (princ (format ";; lint-org: file=%s not readable — skipping\n" + file))))))) + +(defun lo--cli-invocation-p () + "Non-nil when the trailing command-line arguments look like a real invocation: +only recognized flags and/or readable file paths. Lets the ERT suite `require' +this file without firing the CLI dispatch — under `ert-run-tests-batch-and-exit' +the trailing args are things like `-f ert-run-tests-batch-and-exit'." + (and command-line-args-left + (cl-every (lambda (a) + (cond ((member a '("--check")) t) + ((string-prefix-p "--followups-file=" a) t) + ((string-prefix-p "-" a) nil) + (t (file-readable-p a)))) + command-line-args-left))) + +(when (and noninteractive (lo--cli-invocation-p)) + (lo-main)) + +(provide 'lint-org) +;;; lint-org.el ends here diff --git a/claude-templates/.ai/scripts/maildir-flag-manager.py b/claude-templates/.ai/scripts/maildir-flag-manager.py new file mode 100755 index 0000000..97ed1d8 --- /dev/null +++ b/claude-templates/.ai/scripts/maildir-flag-manager.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""Manage maildir flags (read, starred) across email accounts. + +Uses atomic os.rename() for flag operations directly on maildir files. +Safer and more reliable than shell-based approaches (zsh loses PATH in +while-read loops, piped mu move silently fails). + +Supports the same flag semantics as mu4e: maildir files in new/ are moved +to cur/ when the Seen flag is added, and flag changes are persisted to the +filesystem so mbsync picks them up on the next sync. + +Usage: + # Mark all unread INBOX emails as read + maildir-flag-manager.py mark-read + + # Mark specific emails as read (by path) + maildir-flag-manager.py mark-read /path/to/message1 /path/to/message2 + + # Mark all unread INBOX emails as read, then reindex mu + maildir-flag-manager.py mark-read --reindex + + # Star specific emails (by path) + maildir-flag-manager.py star /path/to/message1 /path/to/message2 + + # Star and mark read + maildir-flag-manager.py star --mark-read /path/to/message1 + + # Dry run — show what would change without modifying anything + maildir-flag-manager.py mark-read --dry-run +""" + +import argparse +import os +import shutil +import subprocess +import sys + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +MAILDIR_ACCOUNTS = { + 'gmail': os.path.expanduser('~/.mail/gmail/INBOX'), + 'cmail': os.path.expanduser('~/.mail/cmail/Inbox'), +} + + +# --------------------------------------------------------------------------- +# Core flag operations +# --------------------------------------------------------------------------- + +def parse_maildir_flags(filename): + """Extract flags from a maildir filename. + + Maildir filenames follow the pattern: unique:2,FLAGS + where FLAGS is a sorted string of flag characters (e.g., "FS" for + Flagged+Seen). + + Returns (base, flags_string). If no flags section, returns (filename, ''). + """ + if ':2,' in filename: + base, flags = filename.rsplit(':2,', 1) + return base, flags + return filename, '' + + +def build_flagged_filename(filename, new_flags): + """Build a maildir filename with the given flags. + + Flags are always sorted alphabetically per maildir spec. + """ + base, _ = parse_maildir_flags(filename) + sorted_flags = ''.join(sorted(set(new_flags))) + return f"{base}:2,{sorted_flags}" + + +def rename_with_flag(file_path, flag, dry_run=False): + """Add a flag to a single maildir message file via atomic rename. + + Handles moving from new/ to cur/ when adding the Seen flag. + Returns True if the flag was added, False if already present. + """ + dirname = os.path.dirname(file_path) + filename = os.path.basename(file_path) + maildir_root = os.path.dirname(dirname) + subdir = os.path.basename(dirname) + + _, current_flags = parse_maildir_flags(filename) + + if flag in current_flags: + return False + + new_flags = current_flags + flag + new_filename = build_flagged_filename(filename, new_flags) + + # Messages with the Seen flag belong in cur/, not new/ + if 'S' in new_flags and subdir == 'new': + target_dir = os.path.join(maildir_root, 'cur') + else: + target_dir = dirname + + new_path = os.path.join(target_dir, new_filename) + + if dry_run: + return True + + os.rename(file_path, new_path) + return True + + +def process_maildir(maildir_path, flag, dry_run=False): + """Add a flag to all messages in a maildir that don't have it. + + Scans both new/ and cur/ subdirectories. + Returns (changed_count, skipped_count, error_count). + """ + if not os.path.isdir(maildir_path): + print(f" Skipping {maildir_path} (not found)", file=sys.stderr) + return 0, 0, 0 + + # Snapshot the file list before any rename. Adding S to a new/ file + # moves it to cur/ via rename_with_flag; without a snapshot, the + # moved file gets re-encountered during the cur/ scan and inflates + # the skipped count. + file_paths = [] + for subdir in ('new', 'cur'): + subdir_path = os.path.join(maildir_path, subdir) + if not os.path.isdir(subdir_path): + continue + for filename in os.listdir(subdir_path): + file_path = os.path.join(subdir_path, filename) + if os.path.isfile(file_path): + file_paths.append(file_path) + + changed = 0 + skipped = 0 + errors = 0 + + for file_path in file_paths: + try: + if rename_with_flag(file_path, flag, dry_run): + changed += 1 + else: + skipped += 1 + except Exception as e: + print(f" Error on {os.path.basename(file_path)}: {e}", + file=sys.stderr) + errors += 1 + + return changed, skipped, errors + + +def process_specific_files(paths, flag, dry_run=False): + """Add a flag to specific message files by path. + + Returns (changed_count, skipped_count, error_count). + """ + changed = 0 + skipped = 0 + errors = 0 + + for path in paths: + path = os.path.abspath(path) + if not os.path.isfile(path): + print(f" File not found: {path}", file=sys.stderr) + errors += 1 + continue + + # Verify file is inside a maildir (parent should be cur/ or new/) + parent_dir = os.path.basename(os.path.dirname(path)) + if parent_dir not in ('cur', 'new'): + print(f" Not in a maildir cur/ or new/ dir: {path}", + file=sys.stderr) + errors += 1 + continue + + try: + if rename_with_flag(path, flag, dry_run): + changed += 1 + else: + skipped += 1 + except Exception as e: + print(f" Error on {path}: {e}", file=sys.stderr) + errors += 1 + + return changed, skipped, errors + + +def reindex_mu(): + """Run mu index to update the database after flag changes.""" + mu_path = shutil.which('mu') + if not mu_path: + print("Warning: mu not found in PATH, skipping reindex", + file=sys.stderr) + return False + + try: + result = subprocess.run( + [mu_path, 'index'], + capture_output=True, text=True, timeout=120 + ) + if result.returncode == 0: + print("mu index: database updated") + return True + else: + print(f"mu index failed: {result.stderr}", file=sys.stderr) + return False + except subprocess.TimeoutExpired: + print("mu index timed out after 120s", file=sys.stderr) + return False + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_mark_read(args): + """Mark emails as read (add Seen flag).""" + flag = 'S' + action = "Marking as read" + if args.dry_run: + action = "Would mark as read" + + total_changed = 0 + total_skipped = 0 + total_errors = 0 + + if args.paths: + print(f"{action}: {len(args.paths)} specific message(s)") + c, s, e = process_specific_files(args.paths, flag, args.dry_run) + total_changed += c + total_skipped += s + total_errors += e + else: + for name, maildir_path in MAILDIR_ACCOUNTS.items(): + print(f"{action} in {name} ({maildir_path})") + c, s, e = process_maildir(maildir_path, flag, args.dry_run) + total_changed += c + total_skipped += s + total_errors += e + if c > 0: + print(f" {c} message(s) marked as read") + if s > 0: + print(f" {s} already read") + + print(f"\nTotal: {total_changed} changed, {total_skipped} already set, " + f"{total_errors} errors") + + if args.reindex and not args.dry_run and total_changed > 0: + reindex_mu() + + return 0 if total_errors == 0 else 1 + + +def cmd_star(args): + """Star/flag emails (add Flagged flag).""" + flag = 'F' + action = "Starring" + if args.dry_run: + action = "Would star" + + if not args.paths: + print("Error: star requires specific message paths", file=sys.stderr) + return 1 + + print(f"{action}: {len(args.paths)} message(s)") + total_changed = 0 + total_skipped = 0 + total_errors = 0 + + c, s, e = process_specific_files(args.paths, flag, args.dry_run) + total_changed += c + total_skipped += s + total_errors += e + + # Also mark as read if requested + if args.mark_read: + print("Also marking as read...") + c2, _, e2 = process_specific_files(args.paths, 'S', args.dry_run) + total_changed += c2 + total_errors += e2 + + print(f"\nTotal: {total_changed} flag(s) changed, {total_skipped} already set, " + f"{total_errors} errors") + + if args.reindex and not args.dry_run and total_changed > 0: + reindex_mu() + + return 0 if total_errors == 0 else 1 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Manage maildir flags (read, starred) across email accounts." + ) + subparsers = parser.add_subparsers(dest='command', required=True) + + # mark-read + p_read = subparsers.add_parser( + 'mark-read', + help="Mark emails as read (add Seen flag)" + ) + p_read.add_argument( + 'paths', nargs='*', + help="Specific message file paths. If omitted, marks all unread " + "messages in configured INBOX maildirs." + ) + p_read.add_argument( + '--reindex', action='store_true', + help="Run mu index after changing flags" + ) + p_read.add_argument( + '--dry-run', action='store_true', + help="Show what would change without modifying anything" + ) + p_read.set_defaults(func=cmd_mark_read) + + # star + p_star = subparsers.add_parser( + 'star', + help="Star/flag emails (add Flagged flag)" + ) + p_star.add_argument( + 'paths', nargs='+', + help="Message file paths to star" + ) + p_star.add_argument( + '--mark-read', action='store_true', + help="Also mark starred messages as read" + ) + p_star.add_argument( + '--reindex', action='store_true', + help="Run mu index after changing flags" + ) + p_star.add_argument( + '--dry-run', action='store_true', + help="Show what would change without modifying anything" + ) + p_star.set_defaults(func=cmd_star) + + args = parser.parse_args() + sys.exit(args.func(args)) + + +if __name__ == '__main__': + main() diff --git a/claude-templates/.ai/scripts/tests/conftest.py b/claude-templates/.ai/scripts/tests/conftest.py new file mode 100644 index 0000000..8d965ab --- /dev/null +++ b/claude-templates/.ai/scripts/tests/conftest.py @@ -0,0 +1,77 @@ +"""Shared fixtures for EML extraction tests.""" + +import os +from email.message import EmailMessage +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to the fixtures/ directory.""" + return os.path.join(os.path.dirname(__file__), 'fixtures') + + +def make_plain_message(body="Test body", from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Wed, 05 Feb 2026 11:36:00 -0600"): + """Create an EmailMessage with text/plain body.""" + msg = EmailMessage() + msg['From'] = from_ + msg['To'] = to + msg['Subject'] = subject + msg['Date'] = date + msg.set_content(body) + return msg + + +def make_html_message(html_body="<p>Test body</p>", + from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Wed, 05 Feb 2026 11:36:00 -0600"): + """Create an EmailMessage with text/html body only.""" + msg = EmailMessage() + msg['From'] = from_ + msg['To'] = to + msg['Subject'] = subject + msg['Date'] = date + msg.set_content(html_body, subtype='html') + return msg + + +def make_message_with_attachment(body="Test body", + from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Wed, 05 Feb 2026 11:36:00 -0600", + attachment_filename="document.pdf", + attachment_content=b"fake pdf content"): + """Create a multipart message with a text body and one attachment.""" + msg = MIMEMultipart() + msg['From'] = from_ + msg['To'] = to + msg['Subject'] = subject + msg['Date'] = date + + msg.attach(MIMEText(body, 'plain')) + + att = MIMEApplication(attachment_content, Name=attachment_filename) + att['Content-Disposition'] = f'attachment; filename="{attachment_filename}"' + msg.attach(att) + + return msg + + +def add_received_headers(msg, headers): + """Add Received headers to an existing message. + + headers: list of header strings, added in order (first = most recent). + """ + for header in headers: + msg['Received'] = header + return msg diff --git a/claude-templates/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml b/claude-templates/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml new file mode 100644 index 0000000..827d4f0 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml @@ -0,0 +1,36 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Re: 4319 Danneel Street +Date: Mon, 27 Apr 2026 23:30:28 +0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary123" + +--boundary123 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Body with three inlined copies of the same signature image, mimicking +the way Outlook embeds a sender's signature once per quoted reply level. + +--boundary123 +Content-Type: image/png; name="Outlook-Ricci Part.png" +Content-Disposition: inline; filename="Outlook-Ricci Part.png" +Content-Transfer-Encoding: base64 + +aW1hZ2UtY29udGVudC0x + +--boundary123 +Content-Type: image/png; name="Outlook-Ricci Part.png" +Content-Disposition: inline; filename="Outlook-Ricci Part.png" +Content-Transfer-Encoding: base64 + +aW1hZ2UtY29udGVudC0y + +--boundary123 +Content-Type: image/png; name="Outlook-Ricci Part.png" +Content-Disposition: inline; filename="Outlook-Ricci Part.png" +Content-Transfer-Encoding: base64 + +aW1hZ2UtY29udGVudC0z + +--boundary123-- diff --git a/claude-templates/.ai/scripts/tests/fixtures/empty-body.eml b/claude-templates/.ai/scripts/tests/fixtures/empty-body.eml new file mode 100644 index 0000000..cf008df --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/empty-body.eml @@ -0,0 +1,16 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Empty Body Test +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary456" +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +--boundary456 +Content-Type: application/octet-stream; name="data.bin" +Content-Disposition: attachment; filename="data.bin" +Content-Transfer-Encoding: base64 + +AQIDBA== + +--boundary456-- diff --git a/claude-templates/.ai/scripts/tests/fixtures/html-only.eml b/claude-templates/.ai/scripts/tests/fixtures/html-only.eml new file mode 100644 index 0000000..4db7645 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/html-only.eml @@ -0,0 +1,20 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: HTML Update +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +<html> +<body> +<p>Hi Craig,</p> +<p>Here is the <strong>HTML</strong> update.</p> +<ul> +<li>Item one</li> +<li>Item two</li> +</ul> +<p>Best,<br>Jonathan</p> +</body> +</html> diff --git a/claude-templates/.ai/scripts/tests/fixtures/multiple-received-headers.eml b/claude-templates/.ai/scripts/tests/fixtures/multiple-received-headers.eml new file mode 100644 index 0000000..1b8d6a7 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/multiple-received-headers.eml @@ -0,0 +1,12 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Multiple Received Headers Test +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit +Received: by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600 +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 +Received: from originator.example.com by relay.example.com with SMTP; Thu, 05 Feb 2026 11:35:58 -0600 + +Test body with multiple received headers. diff --git a/claude-templates/.ai/scripts/tests/fixtures/no-received-headers.eml b/claude-templates/.ai/scripts/tests/fixtures/no-received-headers.eml new file mode 100644 index 0000000..8a05dc7 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/no-received-headers.eml @@ -0,0 +1,9 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: No Received Headers +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Test body with no received headers at all. diff --git a/claude-templates/.ai/scripts/tests/fixtures/plain-text.eml b/claude-templates/.ai/scripts/tests/fixtures/plain-text.eml new file mode 100644 index 0000000..8cc9d9c --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/plain-text.eml @@ -0,0 +1,15 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Re: Fw: 4319 Danneel Street +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +Hi Craig, + +Here is the update on 4319 Danneel Street. + +Best, +Jonathan diff --git a/claude-templates/.ai/scripts/tests/fixtures/todo-sample.org b/claude-templates/.ai/scripts/tests/fixtures/todo-sample.org new file mode 100644 index 0000000..8b9e723 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/todo-sample.org @@ -0,0 +1,37 @@ +#+TITLE: Sample todo.org for todo-cleanup tests +#+AUTHOR: synthetic fixture + +# A deliberately varied (but synthetic) todo.org: umbrella "Open Work" / +# "Resolved" headings, mixed TODO/DOING/WAITING/DONE/CANCELLED states, +# priorities, tags, nested level-3 children, and a few structural (no-state) +# section headings. `--archive-done' should move only the direct level-2 +# DONE/CANCELLED subtrees from "Open Work" into "Resolved", intact, and leave +# everything else alone. + +* Sample Open Work +** TODO [#A] Write the README + This one stays — still open. +** DOING [#A] Refactor the parser + In progress; stays. +** DONE [#A] Bootstrap the test harness :tooling: + Finished. Should move to Resolved with this body intact. +** WAITING [#B] Vendor reply on the licensing question + Blocked, not done — stays. +** A grouping heading with no TODO state +*** TODO [#B] sub-task one +*** DONE [#C] sub-task two — done, but nested under an open parent, so stays +** CANCELLED [#B] Drop the legacy importer :chore: + Decided against it. Should move to Resolved. +** TODO [#B] Ship the migration :quick: +*** DONE [#C] write the up migration +*** TODO [#C] write the down migration +** DONE [#B] Tag the 1.0 release +*** DONE [#C] update the changelog +*** TODO [#C] announce on the list + Parent is DONE, so the whole subtree (open child included) moves. +** NEXT [#C] Pick the next milestone + +* Sample Resolved +** DONE [#A] Initial project skeleton + Pre-existing archived entry; new arrivals append after this one. +** CANCELLED [#C] Evaluate the other framework diff --git a/claude-templates/.ai/scripts/tests/fixtures/with-attachment.eml b/claude-templates/.ai/scripts/tests/fixtures/with-attachment.eml new file mode 100644 index 0000000..ac49c5d --- /dev/null +++ b/claude-templates/.ai/scripts/tests/fixtures/with-attachment.eml @@ -0,0 +1,27 @@ +From: Jonathan Smith <jsmith@example.com> +To: Craig Jennings <craig@example.com> +Subject: Ltr from Carrollton +Date: Thu, 05 Feb 2026 11:36:00 -0600 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary123" +Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600 + +--boundary123 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Hi Craig, + +Please find the letter attached. + +Best, +Jonathan + +--boundary123 +Content-Type: application/octet-stream; name="Ltr Carrollton.pdf" +Content-Disposition: attachment; filename="Ltr Carrollton.pdf" +Content-Transfer-Encoding: base64 + +ZmFrZSBwZGYgY29udGVudA== + +--boundary123-- diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el new file mode 100644 index 0000000..8e1ebc4 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -0,0 +1,465 @@ +;;; test-lint-org.el --- ERT tests for lint-org.el -*- lexical-binding: t; -*- +;; +;; Run from the repo root: +;; emacs --batch -q -L .ai/scripts -l ert \ +;; -l .ai/scripts/tests/test-lint-org.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; or from .ai/scripts/tests/: +;; emacs --batch -q -L .. -l ert -l test-lint-org.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; Covers: mechanical auto-fixers (item-number, missing-language-in-src-block, +;; misplaced-planning-info, markdown-bold case of misplaced-heading) and +;; judgment-item emission (link-to-local-file, invalid-fuzzy-link, +;; verbatim-asterisk case of misplaced-heading, suspicious-language-in-src-block, +;; unhandled checkers). + +(require 'ert) +(require 'cl-lib) + +(defconst lo-test--dir + (file-name-directory (or load-file-name buffer-file-name default-directory)) + "Directory of this test file, captured at load time.") + +(add-to-list 'load-path (expand-file-name ".." lo-test--dir)) +(require 'lint-org) + +;;; --------------------------------------------------------------------------- +;;; Harness + +(defun lo-test--reset (&optional check followups-file) + (setq lo-fixes 0 lo-issues nil + lo-check-only (and check t) + lo-current-file nil + lo-followups-file followups-file)) + +(defun lo-test--drop-buffer (file) + (let ((buf (find-buffer-visiting file))) + (when buf + (with-current-buffer buf (set-buffer-modified-p nil)) + (kill-buffer buf)))) + +(defun lo-test--run (content &optional runs check) + "Write CONTENT to a temp .org file, run lint-org RUNS times (default 1). +Return a plist :result (final file contents) :fixes (last run) +:issues (last run). CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "lo-test-" nil ".org")) + last-fixes last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (lo-test--reset check) + (lo-process-file file) + (setq last-fixes lo-fixes last-issues lo-issues) + (lo-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :fixes last-fixes + :issues last-issues)) + (lo-test--drop-buffer file) + (delete-file file)))) + +(defun lo-test--judgments (issues) + "Return judgment items from ISSUES, in document order." + (reverse + (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'judgment)) issues))) + +(defun lo-test--mechanical (issues) + "Return mechanical-fixed items from ISSUES, in document order." + (reverse + (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed)) + issues))) + +(defun lo-test--checkers (items) + (mapcar (lambda (i) (plist-get i :checker)) items)) + +(defun lo-test--has (string substring) + (and (string-match-p (regexp-quote substring) string) t)) + +;;; --------------------------------------------------------------------------- +;;; Fixtures + +;; item-number — bullets 4. and 5. where org expects items 3 and 4. +(defconst lo-test--item-number "\ +* Heading + +1. first +2. second + +4. out-of-order +5. and another +") + +(defconst lo-test--item-number-already-tagged "\ +* Heading + +1. first +2. second + +4. [@4] already tagged +5. [@5] also already tagged +") + +;; missing-language-in-src-block — bare #+begin_src ... #+end_src. +(defconst lo-test--bare-src "\ +* Heading + +#+begin_src +some prose without a language +#+end_src +") + +;; A src block with a language slug doesn't trip the missing-language checker. +(defconst lo-test--src-with-language "\ +* Heading + +#+begin_src text +some prose with a language +#+end_src +") + +;; misplaced-planning-info — CLOSED and DEADLINE on separate lines. +(defconst lo-test--planning-split "\ +* DONE Task +CLOSED: [2026-05-14] +DEADLINE: <2026-05-20> + +Body. +") + +;; misplaced-heading, markdown-bold case — **X.** at start of body paragraph. +(defconst lo-test--md-bold "\ +* Heading + +**Important.** Body continues here. + +More body. +") + +;; misplaced-heading, verbatim-asterisk case — =*** Foo= inside body prose. +(defconst lo-test--verbatim-asterisk "\ +* Heading + +A reference to =*** Foo= inside body prose. +") + +;; link-to-local-file — broken file: link. +(defconst lo-test--broken-file-link "\ +* Heading + +See [[file:/tmp/does-not-exist-lo-test.org][a link]]. +") + +;; invalid-fuzzy-link — link to a heading that doesn't exist in this file. +(defconst lo-test--broken-fuzzy-link "\ +* Heading + +See [[*Nonexistent Heading]]. +") + +;; suspicious-language-in-src-block — #+begin_src markdown. +(defconst lo-test--suspicious-language "\ +* Heading + +#+begin_src markdown +content +#+end_src +") + +;; Mixed fixture — each category once. +(defconst lo-test--mixed "\ +* Mixed + +1. first +2. second + +4. out-of-order + +** DONE Task +CLOSED: [2026-05-14] +DEADLINE: <2026-05-20> + +**Important.** Body. + +A reference to =*** Foo= inside body. + +See [[file:/tmp/does-not-exist-lo-test.org][a link]]. + +See [[*Nonexistent Heading]]. + +#+begin_src +prose +#+end_src + +#+begin_src markdown +content +#+end_src +") + +;;; --------------------------------------------------------------------------- +;;; item-number tests + +(ert-deftest lo-item-number-adds-counter-directive () + (let* ((out (lo-test--run lo-test--item-number)) + (res (plist-get out :result))) + (should (>= (plist-get out :fixes) 1)) + (should (lo-test--has res "4. [@4] out-of-order")) + (should (lo-test--has res "5. [@5] and another")) + ;; well-formed bullets above stay alone + (should (lo-test--has res "1. first")) + (should (lo-test--has res "2. second")))) + +(ert-deftest lo-item-number-skips-already-tagged () + (let ((out (lo-test--run lo-test--item-number-already-tagged))) + (should (= 0 (plist-get out :fixes))) + (should (equal lo-test--item-number-already-tagged (plist-get out :result))))) + +(ert-deftest lo-item-number-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--item-number 1) :result)) + (twice (plist-get (lo-test--run lo-test--item-number 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; missing-language-in-src-block tests + +(ert-deftest lo-bare-src-becomes-example () + (let* ((out (lo-test--run lo-test--bare-src)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :fixes))) + (should (lo-test--has res "#+begin_example")) + (should (lo-test--has res "#+end_example")) + (should-not (lo-test--has res "#+begin_src\n")) + (should-not (lo-test--has res "#+end_src")) + (should (lo-test--has res "some prose without a language")))) + +(ert-deftest lo-src-with-language-stays () + (let ((out (lo-test--run lo-test--src-with-language))) + (should (= 0 (plist-get out :fixes))) + (should (equal lo-test--src-with-language (plist-get out :result))))) + +(ert-deftest lo-bare-src-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--bare-src 1) :result)) + (twice (plist-get (lo-test--run lo-test--bare-src 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; misplaced-planning-info tests + +(ert-deftest lo-planning-info-merges-onto-one-line () + (let* ((out (lo-test--run lo-test--planning-split)) + (res (plist-get out :result))) + (should (>= (plist-get out :fixes) 1)) + ;; Both keywords on the same line, exactly one blank space between values. + (should (string-match-p + "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20" + res)) + ;; No stray DEADLINE: line on its own. + (should-not (string-match-p "^DEADLINE: <2026-05-20" res)))) + +(ert-deftest lo-planning-info-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--planning-split 1) :result)) + (twice (plist-get (lo-test--run lo-test--planning-split 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; misplaced-heading tests + +(ert-deftest lo-markdown-bold-becomes-single-asterisk () + (let* ((out (lo-test--run lo-test--md-bold)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :fixes))) + (should (lo-test--has res "*Important.* Body continues here.")) + (should-not (lo-test--has res "**Important.**")))) + +(ert-deftest lo-markdown-bold-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--md-bold 1) :result)) + (twice (plist-get (lo-test--run lo-test--md-bold 2) :result))) + (should (equal once twice)))) + +(ert-deftest lo-verbatim-asterisk-is-judgment () + (let* ((out (lo-test--run lo-test--verbatim-asterisk)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + ;; File untouched. + (should (equal lo-test--verbatim-asterisk res)) + (should (= 0 (plist-get out :fixes))) + ;; Emitted as judgment with the misplaced-heading checker. + (should (member 'misplaced-heading (lo-test--checkers judgments))))) + +;;; --------------------------------------------------------------------------- +;;; Judgment-category emission tests + +(ert-deftest lo-broken-file-link-is-judgment () + (let* ((out (lo-test--run lo-test--broken-file-link)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (equal lo-test--broken-file-link res)) + (should (= 0 (plist-get out :fixes))) + (should (member 'link-to-local-file (lo-test--checkers judgments))))) + +(ert-deftest lo-broken-fuzzy-link-is-judgment () + (let* ((out (lo-test--run lo-test--broken-fuzzy-link)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (equal lo-test--broken-fuzzy-link res)) + (should (= 0 (plist-get out :fixes))) + (should (member 'invalid-fuzzy-link (lo-test--checkers judgments))))) + +(ert-deftest lo-suspicious-language-is-judgment () + (let* ((out (lo-test--run lo-test--suspicious-language)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (equal lo-test--suspicious-language res)) + (should (= 0 (plist-get out :fixes))) + (should (member 'suspicious-language-in-src-block + (lo-test--checkers judgments))))) + +;;; --------------------------------------------------------------------------- +;;; --check mode + +(ert-deftest lo-check-mode-does-not-modify-file () + (let* ((out (lo-test--run lo-test--mixed 1 t)) + (res (plist-get out :result))) + (should (equal lo-test--mixed res)))) + +(ert-deftest lo-check-mode-reports-mechanical-and-judgment () + (let* ((out (lo-test--run lo-test--mixed 1 t)) + (issues (plist-get out :issues)) + (kinds (cl-remove-duplicates + (mapcar (lambda (i) (plist-get i :kind)) issues)))) + ;; Both kinds appear — check mode reports would-fix entries as + ;; mechanical-fixed and judgment items as judgment, no writes. + (should (member 'mechanical-fixed kinds)) + (should (member 'judgment kinds)))) + +;;; --------------------------------------------------------------------------- +;;; Mixed-fixture integration + +(ert-deftest lo-mixed-fixture-applies-all-mechanical-and-emits-judgment () + (let* ((out (lo-test--run lo-test--mixed)) + (res (plist-get out :result)) + (judgment-checkers + (cl-remove-duplicates + (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))) + ;; Mechanical: every flagged item-number, bare-src, planning, md-bold fixed. + (should (>= (plist-get out :fixes) 4)) + (should (lo-test--has res "4. [@4] out-of-order")) + (should (lo-test--has res "#+begin_example")) + (should (lo-test--has res "*Important.* Body.")) + (should (string-match-p + "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20" + res)) + ;; Judgment: every flagged broken link, suspicious-language, verbatim-asterisk + ;; emitted untouched. + (should (member 'link-to-local-file judgment-checkers)) + (should (member 'invalid-fuzzy-link judgment-checkers)) + (should (member 'suspicious-language-in-src-block judgment-checkers)) + (should (member 'misplaced-heading judgment-checkers)) + ;; Verbatim-asterisk untouched in the file. + (should (lo-test--has res "=*** Foo=")))) + +(ert-deftest lo-mixed-fixture-is-idempotent () + (let ((once (plist-get (lo-test--run lo-test--mixed 1) :result)) + (twice (plist-get (lo-test--run lo-test--mixed 2) :result))) + (should (equal once twice)))) + +;;; --------------------------------------------------------------------------- +;;; Backup file is created in /tmp + +;;; --------------------------------------------------------------------------- +;;; Follow-ups file behavior + +(ert-deftest lo-followups-file-appends-judgments () + (let ((followups (make-temp-file "lo-followups-" nil ".org")) + (file (make-temp-file "lo-test-fup-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--mixed)) + (with-temp-file followups (insert "")) + (lo-test--reset nil followups) + (lo-process-file file) + (lo-emit-report) + (lo-test--drop-buffer file) + (let ((content (with-temp-buffer + (insert-file-contents followups) + (buffer-string)))) + ;; Dated section header. + (should (string-match-p + (format "^\\* %s lint-org follow-ups" + (format-time-string "%Y-%m-%d")) + content)) + ;; Each judgment is a TODO line referencing checker + line number. + (should (string-match-p "TODO line [0-9]+ — link-to-local-file" content)) + (should (string-match-p "TODO line [0-9]+ — invalid-fuzzy-link" content)) + (should (string-match-p + "TODO line [0-9]+ — suspicious-language-in-src-block" + content)))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file)) + (when (file-exists-p followups) (delete-file followups))))) + +(ert-deftest lo-followups-file-skipped-in-check-mode () + (let ((followups (make-temp-file "lo-followups-" nil ".org")) + (file (make-temp-file "lo-test-fup-check-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--mixed)) + (with-temp-file followups (insert "")) + (lo-test--reset t followups) ; check=t, followups set + (lo-process-file file) + (lo-emit-report) + (lo-test--drop-buffer file) + ;; followups untouched in check mode + (should (equal "" (with-temp-buffer + (insert-file-contents followups) + (buffer-string))))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file)) + (when (file-exists-p followups) (delete-file followups))))) + +(ert-deftest lo-followups-file-noop-when-no-judgments () + ;; A fixture with only mechanical issues should leave the followups file empty. + (let ((followups (make-temp-file "lo-followups-" nil ".org")) + (file (make-temp-file "lo-test-fup-empty-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--item-number)) + (with-temp-file followups (insert "")) + (lo-test--reset nil followups) + (lo-process-file file) + (lo-emit-report) + (lo-test--drop-buffer file) + (should (equal "" (with-temp-buffer + (insert-file-contents followups) + (buffer-string))))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file)) + (when (file-exists-p followups) (delete-file followups))))) + +(ert-deftest lo-creates-backup-before-modifying () + (let ((file (make-temp-file "lo-test-bak-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert lo-test--bare-src)) + (lo-test--reset) + (lo-process-file file) + (lo-test--drop-buffer file) + ;; Backup pattern in lint-org.el: /tmp/<basename>.before-lint-pass.<timestamp> + (let* ((basename (file-name-nondirectory file)) + (backups (directory-files "/tmp" t + (concat (regexp-quote basename) + "\\.before-lint-pass\\.")))) + (should (>= (length backups) 1)) + ;; Backup content matches pre-fix content. + (let ((backup (car backups))) + (with-temp-buffer + (insert-file-contents backup) + (should (equal lo-test--bare-src (buffer-string)))) + (delete-file backup)))) + (lo-test--drop-buffer file) + (when (file-exists-p file) (delete-file file))))) + +(provide 'test-lint-org) +;;; test-lint-org.el ends here diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el new file mode 100644 index 0000000..5d43f97 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el @@ -0,0 +1,518 @@ +;;; test-todo-cleanup.el --- ERT tests for todo-cleanup.el -*- lexical-binding: t; -*- +;; +;; Run from the repo root: +;; emacs --batch -q -L .ai/scripts -l ert \ +;; -l .ai/scripts/tests/test-todo-cleanup.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; or from .ai/scripts/tests/: +;; emacs --batch -q -L .. -l ert -l test-todo-cleanup.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; Covers the `--archive-done' mode: moving level-2 DONE/CANCELLED subtrees +;; out of the "Open Work" section into the "Resolved" section. + +(require 'ert) +(require 'cl-lib) + +(defconst tc-test--dir + (file-name-directory (or load-file-name buffer-file-name default-directory)) + "Directory of this test file, captured at load time.") + +;; Make `todo-cleanup' loadable from the parent directory. Loading it is +;; inert: its CLI dispatch only fires when the trailing command-line args look +;; like a real invocation (recognized flags / readable file paths), which they +;; don't during `ert-run-tests-batch-and-exit'. +(add-to-list 'load-path (expand-file-name ".." tc-test--dir)) +(require 'todo-cleanup) + +;;; --------------------------------------------------------------------------- +;;; Harness + +(defun tc-test--reset (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done t tc-sync-child-priority nil + tc-current-file nil)) + +(defun tc-test--reset-sync (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done nil tc-sync-child-priority t + tc-current-file nil)) + +(defun tc-test--drop-buffer (file) + (let ((buf (find-buffer-visiting file))) + (when buf + (with-current-buffer buf (set-buffer-modified-p nil)) + (kill-buffer buf)))) + +(defun tc-test--archive (content &optional runs check) + "Write CONTENT to a temp .org file, run `--archive-done' RUNS times (default 1). +Return a plist: :result final file contents, :archived count from the last run, +:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "tc-test-" nil ".org")) + last-archived last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset check) + (tc-process-file file) + (setq last-archived tc-archived last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :archived last-archived + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +(defun tc-test--section (content needle) + "Text of the level-1 section in CONTENT whose heading line contains NEEDLE — +from the heading line through (not including) the next level-1 heading or EOF." + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (let (start) + (while (and (not start) (re-search-forward "^\\* .*$" nil t)) + (when (string-match-p (regexp-quote needle) (match-string 0)) + (setq start (match-beginning 0)))) + (unless start (error "no level-1 heading containing %S" needle)) + (goto-char start) + (forward-line 1) + (buffer-substring-no-properties + start + (if (re-search-forward "^\\* " nil t) (match-beginning 0) (point-max)))))) + +(defun tc-test--has (string substring) + (and (string-match-p (regexp-quote substring) string) t)) + +(defun tc-test--before-p (string a b) + "Non-nil when SUBSTRING A occurs before SUBSTRING B in STRING." + (let ((ia (string-match (regexp-quote a) string)) + (ib (string-match (regexp-quote b) string))) + (and ia ib (< ia ib)))) + +(defun tc-test--skip-detail (issues) + (let ((skip (cl-find-if (lambda (i) (eq (plist-get i :kind) 'archive-skip)) issues))) + (and skip (plist-get skip :detail)))) + +(defun tc-test--moved-headings (issues) + (mapcar (lambda (i) (plist-get i :heading)) + (cl-remove-if-not + (lambda (i) (memq (plist-get i :kind) '(archive-moved archive-would))) + (reverse issues)))) + +;;; --------------------------------------------------------------------------- +;;; Fixtures (synthetic — real project todo.org files are examples only) + +(defconst tc-test--basic "\ +* Demo Open Work +** TODO [#A] First open task + first body +** DONE [#A] A finished task + finished body +** TODO [#B] Another open task +* Demo Resolved +** DONE [#A] Previously archived +") + +(defconst tc-test--mixed "\ +* Proj Open Work +** TODO Keep me open +** DONE Done one +*** TODO leftover child of done one +** A structural heading with no state +** CANCELLED Cancelled two :quick: +** TODO Has a done child +*** DONE this nested done stays +** DONE Done three +* Proj Resolved +** DONE Old archived item +") + +(defconst tc-test--nothing "\ +* X Open Work +** TODO a +** WAITING b +** NEXT c +* X Resolved +** DONE old +") + +(defconst tc-test--no-resolved "\ +* Y Open Work +** DONE finished +** TODO ongoing +") + +(defconst tc-test--no-open "\ +* Z Resolved +** DONE old +* Some Other Section +** TODO whatever +") + +(defconst tc-test--two-resolved "\ +* P Open Work +** DONE done +* P Resolved +** DONE old1 +* Q Resolved Notes +** DONE old2 +") + +;; No trailing newline — exercises the EOF / final-line case. Open Work is the +;; last section, so a DONE level-2 here is also the last subtree in the file. +(defconst tc-test--eof "\ +* W Resolved +** DONE pre-existing +* W Open Work +** TODO keep open +** DONE last thing + body of last thing") + +(defconst tc-test--lowercase "\ +* winvm open work +** TODO test rebuilt vm +** DONE fix display resolution +* winvm resolved +** DONE fork linoffice as winvm +") + +;;; --------------------------------------------------------------------------- +;;; Tests + +(ert-deftest tc-archive-moves-one-done-level-2 () + (let* ((out (tc-test--archive tc-test--basic)) + (res (plist-get out :result)) + (open (tc-test--section res "Demo Open Work")) + (resolved (tc-test--section res "Demo Resolved"))) + (should (= 1 (plist-get out :archived))) + (should (tc-test--has resolved "A finished task")) + (should (tc-test--has resolved "finished body")) + (should-not (tc-test--has open "A finished task")) + (should (tc-test--has open "First open task")) + (should (tc-test--has open "Another open task")) + ;; appended at the end of the Resolved section + (should (tc-test--before-p resolved "Previously archived" "A finished task")))) + +(ert-deftest tc-archive-moves-multiple-done-and-cancelled () + (let* ((out (tc-test--archive tc-test--mixed)) + (res (plist-get out :result)) + (open (tc-test--section res "Proj Open Work")) + (resolved (tc-test--section res "Proj Resolved"))) + (should (= 3 (plist-get out :archived))) + ;; stays in Open Work + (should (tc-test--has open "Keep me open")) + (should (tc-test--has open "A structural heading with no state")) + (should (tc-test--has open "Has a done child")) + (should (tc-test--has open "this nested done stays")) + ;; moved to Resolved + (should (tc-test--has resolved "Done one")) + (should (tc-test--has resolved "Cancelled two")) + (should (tc-test--has resolved "Done three")) + ;; a level-2 DONE moves its (open) children along with it + (should (tc-test--has resolved "leftover child of done one")) + (should-not (tc-test--has open "leftover child of done one")) + ;; gone from Open Work + (should-not (tc-test--has open "Done one")) + (should-not (tc-test--has open "Cancelled two")) + (should-not (tc-test--has open "Done three")) + ;; order: pre-existing first, then in document order + (should (tc-test--before-p resolved "Old archived item" "Done one")) + (should (tc-test--before-p resolved "Done one" "Cancelled two")) + (should (tc-test--before-p resolved "Cancelled two" "Done three")))) + +(ert-deftest tc-archive-structural-heading-does-not-move () + (let* ((out (tc-test--archive tc-test--mixed)) + (open (tc-test--section (plist-get out :result) "Proj Open Work"))) + (should (tc-test--has open "A structural heading with no state")))) + +(ert-deftest tc-archive-nothing-to-do-is-noop () + (let ((out (tc-test--archive tc-test--nothing))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--nothing (plist-get out :result))))) + +(ert-deftest tc-archive-missing-resolved-section-skips () + (let ((out (tc-test--archive tc-test--no-resolved))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--no-resolved (plist-get out :result))) + (should (string-match-p "Resolved" (or (tc-test--skip-detail (plist-get out :issues)) ""))))) + +(ert-deftest tc-archive-missing-open-work-section-skips () + (let ((out (tc-test--archive tc-test--no-open))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--no-open (plist-get out :result))) + (should (string-match-p "Open Work" (or (tc-test--skip-detail (plist-get out :issues)) ""))))) + +(ert-deftest tc-archive-ambiguous-resolved-section-skips () + (let ((out (tc-test--archive tc-test--two-resolved))) + (should (= 0 (plist-get out :archived))) + (should (equal tc-test--two-resolved (plist-get out :result))) + (should (string-match-p "Resolved" (or (tc-test--skip-detail (plist-get out :issues)) ""))))) + +(ert-deftest tc-archive-subtree-at-eof () + (let* ((out (tc-test--archive tc-test--eof)) + (res (plist-get out :result)) + (open (tc-test--section res "W Open Work")) + (resolved (tc-test--section res "W Resolved"))) + (should (= 1 (plist-get out :archived))) + (should (tc-test--has resolved "last thing")) + (should (tc-test--has resolved "body of last thing")) + (should (tc-test--has open "keep open")) + (should-not (tc-test--has open "last thing")) + ;; result stays well-formed: a newline separates the moved body from the + ;; following section heading + (should (string-match-p "body of last thing\n\\* W Open Work" res)))) + +(ert-deftest tc-archive-matches-lowercase-headings () + (let* ((out (tc-test--archive tc-test--lowercase)) + (res (plist-get out :result)) + (open (tc-test--section res "winvm open work")) + (resolved (tc-test--section res "winvm resolved"))) + (should (= 1 (plist-get out :archived))) + (should (tc-test--has resolved "fix display resolution")) + (should-not (tc-test--has open "fix display resolution")) + (should (tc-test--has open "test rebuilt vm")))) + +(ert-deftest tc-archive-is-idempotent () + (dolist (fixture (list tc-test--basic tc-test--mixed tc-test--eof + tc-test--lowercase tc-test--nothing)) + (let ((once (plist-get (tc-test--archive fixture 1) :result)) + (twice (plist-get (tc-test--archive fixture 2) :result))) + (should (equal once twice))))) + +(ert-deftest tc-archive-check-mode-previews-without-writing () + (let ((out (tc-test--archive tc-test--basic 1 t))) + (should (= 1 (plist-get out :archived))) + (should (equal tc-test--basic (plist-get out :result))) + (should (member "A finished task" (tc-test--moved-headings (plist-get out :issues)))))) + +(ert-deftest tc-archive-check-mode-is-idempotent () + (let ((once (tc-test--archive tc-test--mixed 1 t)) + (twice (tc-test--archive tc-test--mixed 2 t))) + (should (equal tc-test--mixed (plist-get once :result))) + (should (equal tc-test--mixed (plist-get twice :result))) + (should (= 3 (plist-get once :archived))) + (should (= 3 (plist-get twice :archived))))) + +;;; --------------------------------------------------------------------------- +;;; Realistic synthetic sample (committed under fixtures/) + +(defun tc-test--sample-file () + (expand-file-name "fixtures/todo-sample.org" tc-test--dir)) + +(ert-deftest tc-archive-realistic-sample () + (let* ((src (tc-test--sample-file))) + (skip-unless (file-readable-p src)) + (let* ((content (with-temp-buffer (insert-file-contents src) (buffer-string))) + (out (tc-test--archive content)) + (res (plist-get out :result)) + (out2 (tc-test--archive content 2))) + ;; every DONE/CANCELLED level-2 entry under "Open Work" moved out + (let ((open (tc-test--section res "Sample Open Work"))) + (should-not (string-match-p "^\\*\\* \\(DONE\\|CANCELLED\\) " open))) + ;; structural and still-open level-2 entries stayed + (let ((open (tc-test--section res "Sample Open Work"))) + (should (string-match-p "^\\*\\* TODO " open)) + (should (string-match-p "^\\*\\* DOING " open))) + ;; idempotent + (should (equal res (plist-get out2 :result))) + ;; something actually moved + (should (> (plist-get out :archived) 0))))) + +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority harness + fixtures + +(defun tc-test--sync (content &optional runs check) + "Write CONTENT to a temp .org file, run `--sync-child-priority' RUNS times +\(default 1\). Return a plist: :result final file contents, :bumped count from +the last run, :issues from the last run. CHECK non-nil ⇒ --check (preview)." + (let ((file (make-temp-file "tc-test-sync-" nil ".org")) + last-bumped last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-sync check) + (tc-process-file file) + (setq last-bumped tc-bumped last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :bumped last-bumped + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +(defun tc-test--priority-of (content heading-substring) + "Return the priority letter (a string like \"A\") on the first heading line +in CONTENT that contains HEADING-SUBSTRING, or nil if the heading has no +priority cookie." + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (let (found-line found-prio) + (while (and (not found-line) (re-search-forward "^\\*+ .*$" nil t)) + (let ((line (match-string 0))) + (when (string-match-p (regexp-quote heading-substring) line) + (setq found-line line) + (when (string-match "\\[#\\([A-Z]\\)\\]" line) + (setq found-prio (match-string 1 line)))))) + (unless found-line + (error "no heading containing %S" heading-substring)) + found-prio))) + +(defun tc-test--sync-bumped-headings (issues) + "Return the heading texts of every `:kind' sync-bumped or sync-would entry +in ISSUES, in document order." + (mapcar (lambda (i) (plist-get i :child-heading)) + (cl-remove-if-not + (lambda (i) (memq (plist-get i :kind) '(sync-bumped sync-would))) + (reverse issues)))) + +(defconst tc-test--sync-basic "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Drifted child +*** TODO [#B] Already in sync +") + +(defconst tc-test--sync-multi "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Higher-priority child stays +*** TODO [#B] Equal-priority child stays +*** TODO [#C] Lower-priority child bumps +*** TODO [#D] Way-lower-priority child bumps +*** TODO Priority-less child stays +") + +(defconst tc-test--sync-no-sync-tag "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Regular drifted child +*** TODO [#D] Follow-up: opted-out :no-sync: +") + +(defconst tc-test--sync-priority-less-parent "\ +* Open Work +** TODO Parent with no priority +*** TODO [#D] Child with priority should not move +") + +(defconst tc-test--sync-cascade "\ +* Open Work +** TODO [#A] Top +*** TODO [#B] Middle +**** TODO [#D] Leaf +") + +(defconst tc-test--sync-no-change "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Child higher +*** TODO [#B] Child equal +") + +;; A dated-log heading inside a parent task whose title quotes other priorities +;; in =[#X]= verbatim. Those quoted cookies must NOT be read as the heading's +;; own priority — the cookie has to sit in canonical position to count. +(defconst tc-test--sync-cookie-in-title "\ +* Open Work +** TODO [#B] Parent +*** 2026-05-14 Reprioritized children =[#D]= → =[#B]= to match parent +*** TODO [#D] Regular drifted child +") + +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority tests + +(ert-deftest tc-sync-bumps-lower-priority-child () + (let* ((out (tc-test--sync tc-test--sync-basic)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Drifted child"))) + (should (equal "B" (tc-test--priority-of res "Already in sync"))) + (should (equal "B" (tc-test--priority-of res "Parent"))))) + +(ert-deftest tc-sync-leaves-higher-and-equal-children-alone () + (let* ((out (tc-test--sync tc-test--sync-multi)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Higher-priority child"))) + (should (equal "B" (tc-test--priority-of res "Equal-priority child"))) + (should (equal "B" (tc-test--priority-of res "Lower-priority child"))) + (should (equal "B" (tc-test--priority-of res "Way-lower-priority child"))) + (should-not (tc-test--priority-of res "Priority-less child")))) + +(ert-deftest tc-sync-skips-no-sync-tagged-child () + (let* ((out (tc-test--sync tc-test--sync-no-sync-tag)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Regular drifted child"))) + (should (equal "D" (tc-test--priority-of res "Follow-up: opted-out"))))) + +(ert-deftest tc-sync-leaves-priority-less-parent-alone () + (let ((out (tc-test--sync tc-test--sync-priority-less-parent))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-priority-less-parent (plist-get out :result))))) + +(ert-deftest tc-sync-cascades-through-multiple-levels () + (let* ((out (tc-test--sync tc-test--sync-cascade)) + (res (plist-get out :result))) + ;; one pass should collapse [#A] → [#B] → [#D] to all [#A] because + ;; org-map-entries visits the parent first, bumps the middle, then visits + ;; the (now bumped) middle and bumps its leaf + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Top"))) + (should (equal "A" (tc-test--priority-of res "Middle"))) + (should (equal "A" (tc-test--priority-of res "Leaf"))))) + +(ert-deftest tc-sync-no-change-when-all-children-at-or-above-parent () + (let ((out (tc-test--sync tc-test--sync-no-change))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-no-change (plist-get out :result))))) + +(ert-deftest tc-sync-ignores-cookie-shaped-text-in-title () + (let* ((out (tc-test--sync tc-test--sync-cookie-in-title)) + (res (plist-get out :result))) + ;; Only the real drifted child bumps; the dated-log heading with + ;; =[#D]= / =[#B]= verbatim text in its title is untouched. + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Regular drifted child"))) + ;; Substring still appears in the dated-log heading; the heading itself + ;; was not rewritten. + (should (string-match-p "Reprioritized children =\\[#D\\]= → =\\[#B\\]= to match parent" res)))) + +(ert-deftest tc-sync-is-idempotent () + (dolist (fixture (list tc-test--sync-basic + tc-test--sync-multi + tc-test--sync-no-sync-tag + tc-test--sync-priority-less-parent + tc-test--sync-cascade + tc-test--sync-no-change + tc-test--sync-cookie-in-title)) + (let ((once (plist-get (tc-test--sync fixture 1) :result)) + (twice (plist-get (tc-test--sync fixture 2) :result))) + (should (equal once twice))))) + +(ert-deftest tc-sync-check-mode-previews-without-writing () + (let ((out (tc-test--sync tc-test--sync-basic 1 t))) + (should (= 1 (plist-get out :bumped))) + (should (equal tc-test--sync-basic (plist-get out :result))) + (should (member "Drifted child" + (tc-test--sync-bumped-headings (plist-get out :issues)))))) + +(ert-deftest tc-sync-check-mode-is-idempotent () + (let ((once (tc-test--sync tc-test--sync-cascade 1 t)) + (twice (tc-test--sync tc-test--sync-cascade 2 t))) + (should (equal tc-test--sync-cascade (plist-get once :result))) + (should (equal tc-test--sync-cascade (plist-get twice :result))) + (should (= 2 (plist-get once :bumped))) + (should (= 2 (plist-get twice :bumped))))) + +(provide 'test-todo-cleanup) +;;; test-todo-cleanup.el ends here diff --git a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py new file mode 100644 index 0000000..2c8dade --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py @@ -0,0 +1,157 @@ +"""Tests for cj-remove-block.py — idempotent removal of cj annotations by line range. + +The script removes lines [start, end] (1-indexed, inclusive) from an org file but +validates first that those lines actually look like a cj annotation. Refusing on +mismatch protects against accidentally trimming the wrong block when line numbers +drift between scan and remove calls. +""" + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py" + + +@pytest.fixture +def run_remove(tmp_path): + """Write content to a temp org file, run cj-remove-block, return new contents.""" + def _run(content: str, start: int, end: int) -> str: + f = tmp_path / "test.org" + f.write_text(content) + subprocess.run( + ["python3", str(SCRIPT), + "--file", str(f), + "--start", str(start), + "--end", str(end)], + check=True, + capture_output=True, + ) + return f.read_text() + return _run + + +@pytest.fixture +def run_remove_expecting_failure(tmp_path): + """Write content, run cj-remove-block expecting non-zero exit; return CalledProcessError.""" + def _run(content: str, start: int, end: int): + f = tmp_path / "test.org" + f.write_text(content) + with pytest.raises(subprocess.CalledProcessError) as excinfo: + subprocess.run( + ["python3", str(SCRIPT), + "--file", str(f), + "--start", str(start), + "--end", str(end)], + check=True, + capture_output=True, + ) + return excinfo.value, f.read_text() # file should be unchanged on failure + return _run + + +# ---------------------------------------------------------------------- +# Source-block removal +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockSourceBlock: + """Removing #+begin_src cj: ... #+end_src blocks.""" + + def test_cj_remove_block_minimal_three_line_source_block(self, run_remove): + """Normal: the three lines of a minimal source-block are removed.""" + content = "* S\n#+begin_src cj: comment\nbody\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + def test_cj_remove_block_source_block_multiline_body(self, run_remove): + """Normal: source-block with multi-line body removed cleanly.""" + content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=6) + assert result == "* S\nafter\n" + + def test_cj_remove_block_preserves_lines_before_and_after(self, run_remove): + """Normal: surrounding lines outside the range stay intact.""" + content = "before\n#+begin_src cj: comment\nx\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "before\nafter\n" + + def test_cj_remove_block_source_block_with_label_variant(self, run_remove): + """Boundary: source-block with no trailing label (#+begin_src cj:) also removable.""" + content = "* S\n#+begin_src cj:\nbody\n#+end_src\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + def test_cj_remove_block_case_insensitive_fence(self, run_remove): + """Boundary: case-variant fences (#+BEGIN_SRC / #+END_SRC) also removable.""" + content = "* S\n#+BEGIN_SRC cj: comment\nbody\n#+END_SRC\nafter\n" + result = run_remove(content, start=2, end=4) + assert result == "* S\nafter\n" + + +# ---------------------------------------------------------------------- +# Legacy-inline removal +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockLegacyInline: + """Removing single-line legacy `cj: ...` annotations.""" + + def test_cj_remove_block_legacy_inline_single_line(self, run_remove): + """Normal: single legacy-inline cj line removed.""" + content = "* S\ncj: legacy note\nafter\n" + result = run_remove(content, start=2, end=2) + assert result == "* S\nafter\n" + + def test_cj_remove_block_legacy_inline_at_eof(self, run_remove): + """Boundary: legacy-inline cj at last line; file ends cleanly.""" + content = "* S\ncj: at end\n" + result = run_remove(content, start=2, end=2) + assert result == "* S\n" + + +# ---------------------------------------------------------------------- +# Refusal-on-mismatch safety +# ---------------------------------------------------------------------- + +class TestCjRemoveBlockSafety: + """Refuses to remove if the specified range doesn't look like a cj annotation.""" + + def test_cj_remove_block_refuses_non_cj_single_line(self, run_remove_expecting_failure): + """Error: a single non-cj line is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\nthis is not a cj line\nafter\n", start=2, end=2, + ) + assert err.returncode != 0 + # File must be unchanged + assert post_content == "* S\nthis is not a cj line\nafter\n" + + def test_cj_remove_block_refuses_mismatched_fence(self, run_remove_expecting_failure): + """Error: multi-line range where line N isn't an opening fence is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\nbody1\nbody2\n#+end_src\nafter\n", start=2, end=4, + ) + assert err.returncode != 0 + assert "body1" in post_content # file unchanged + + def test_cj_remove_block_refuses_missing_closing_fence(self, run_remove_expecting_failure): + """Error: multi-line range where line M isn't a closing fence is rejected.""" + err, post_content = run_remove_expecting_failure( + "* S\n#+begin_src cj: comment\nbody\nnot-a-close\nafter\n", start=2, end=4, + ) + assert err.returncode != 0 + assert "not-a-close" in post_content + + def test_cj_remove_block_refuses_out_of_bounds(self, run_remove_expecting_failure): + """Error: range outside the file is rejected, file unchanged.""" + err, post_content = run_remove_expecting_failure( + "* S\nafter\n", start=5, end=7, + ) + assert err.returncode != 0 + assert post_content == "* S\nafter\n" + + def test_cj_remove_block_refuses_inverted_range(self, run_remove_expecting_failure): + """Error: end < start is rejected, file unchanged.""" + original = "* S\n#+begin_src cj: comment\nbody\n#+end_src\n" + err, post_content = run_remove_expecting_failure(original, start=4, end=2) + assert err.returncode != 0 + assert post_content == original diff --git a/claude-templates/.ai/scripts/tests/test_cj_scan.py b/claude-templates/.ai/scripts/tests/test_cj_scan.py new file mode 100644 index 0000000..7844474 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cj_scan.py @@ -0,0 +1,250 @@ +"""Tests for cj-scan.py — org-file cj-annotation scanner. + +The script parses an org file and emits JSON describing: +- cj_blocks: every cj annotation found (source-block or legacy-inline form) +- verify_tasks: every VERIFY heading + placement validity (top-level or first-level child only) +- unclosed_blocks: any source-block fence that opened but never closed +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "cj-scan.py" + + +@pytest.fixture +def run_scan(tmp_path): + """Write content to a temp org file and run cj-scan; return parsed JSON output.""" + def _run(content: str) -> dict: + f = tmp_path / "test.org" + f.write_text(content) + result = subprocess.run( + ["python3", str(SCRIPT), str(f)], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + return _run + + +# ---------------------------------------------------------------------- +# cj-block detection +# ---------------------------------------------------------------------- + +class TestCjScanCjBlockDetection: + """Detection of cj annotations — source-block and legacy-inline forms.""" + + def test_cj_scan_source_block_single_detected(self, run_scan): + """Normal: a single source-block cj is detected with correct line range and body.""" + content = "* Section\n#+begin_src cj: comment\nplease check this\n#+end_src\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + b = result["cj_blocks"][0] + assert b["form"] == "source-block" + assert b["body"] == "please check this" + assert b["start_line"] == 2 + assert b["end_line"] == 4 + + def test_cj_scan_source_block_multiline_body_preserved(self, run_scan): + """Normal: multi-line body is preserved with embedded newlines.""" + content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\n" + result = run_scan(content) + assert result["cj_blocks"][0]["body"] == "line 1\nline 2\nline 3" + + def test_cj_scan_multiple_source_blocks_each_detected(self, run_scan): + """Normal: multiple source-blocks in a file are detected as separate items.""" + content = ( + "* A\n#+begin_src cj: comment\nfirst\n#+end_src\n" + "* B\n#+begin_src cj: comment\nsecond\n#+end_src\n" + ) + result = run_scan(content) + assert len(result["cj_blocks"]) == 2 + bodies = [b["body"] for b in result["cj_blocks"]] + assert bodies == ["first", "second"] + + def test_cj_scan_legacy_inline_single_line_detected(self, run_scan): + """Normal: a legacy inline cj line is detected with form=legacy-inline.""" + content = "* Section\ncj: please check this\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + b = result["cj_blocks"][0] + assert b["form"] == "legacy-inline" + assert b["body"] == "please check this" + assert b["start_line"] == 2 + assert b["end_line"] == 2 + + def test_cj_scan_mixed_forms_in_same_file(self, run_scan): + """Normal: source-block + legacy inline coexist; both detected as separate items.""" + content = ( + "* A\ncj: legacy form\n" + "* B\n#+begin_src cj: comment\nnew form\n#+end_src\n" + ) + result = run_scan(content) + assert len(result["cj_blocks"]) == 2 + forms = sorted(b["form"] for b in result["cj_blocks"]) + assert forms == ["legacy-inline", "source-block"] + + def test_cj_scan_empty_file_returns_empty_lists(self, run_scan): + """Boundary: empty file → empty cj_blocks and verify_tasks lists.""" + result = run_scan("") + assert result["cj_blocks"] == [] + assert result["verify_tasks"] == [] + assert result["unclosed_blocks"] == [] + + def test_cj_scan_no_cj_content_returns_empty_blocks(self, run_scan): + """Boundary: org file with no cj content → empty cj_blocks.""" + content = "* Section\n** TODO Task\nbody text\n** TODO Another\n" + result = run_scan(content) + assert result["cj_blocks"] == [] + + def test_cj_scan_block_before_any_heading_empty_chain(self, run_scan): + """Boundary: cj block at top of file (before any heading) → empty parent chain.""" + content = "#+begin_src cj: comment\ntop-level note\n#+end_src\n" + result = run_scan(content) + assert result["cj_blocks"][0]["parent_heading_chain"] == [] + assert result["cj_blocks"][0]["parent_depth"] == 0 + + @pytest.mark.parametrize("fence", [ + "#+begin_src cj: comment", + "#+begin_src cj:", + "#+begin_src cj: anything", + "#+BEGIN_SRC cj: comment", # case-insensitive + ]) + def test_cj_scan_source_block_fence_variants_all_recognized(self, run_scan, fence): + """Boundary: fence label and case variants are all valid forms.""" + content = f"* S\n{fence}\nbody\n#+end_src\n" + result = run_scan(content) + assert len(result["cj_blocks"]) == 1 + assert result["cj_blocks"][0]["body"] == "body" + + def test_cj_scan_unclosed_source_block_reported(self, run_scan): + """Error: a source-block that opens but never closes → reported in unclosed_blocks.""" + content = "* S\n#+begin_src cj: comment\nbody that never ends\n" + result = run_scan(content) + assert result["cj_blocks"] == [] + assert len(result["unclosed_blocks"]) == 1 + assert result["unclosed_blocks"][0]["start_line"] == 2 + + +# ---------------------------------------------------------------------- +# Parent heading chain reconstruction +# ---------------------------------------------------------------------- + +class TestCjScanParentChain: + """Parent heading chain construction — walking the org tree backward.""" + + def test_cj_scan_nested_parent_chain_three_levels(self, run_scan): + """Normal: cj block inside three nested headings → chain reflects all three.""" + content = ( + "* Work\n" + "** DOING [#A] Kostya's contract\n" + "*** VERIFY Question?\n" + "#+begin_src cj: comment\nanswer\n#+end_src\n" + ) + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert len(chain) == 3 + assert chain[0] == {"depth": 1, "heading": "Work"} + assert chain[1] == {"depth": 2, "heading": "DOING [#A] Kostya's contract"} + assert chain[2] == {"depth": 3, "heading": "VERIFY Question?"} + assert result["cj_blocks"][0]["parent_depth"] == 3 + + def test_cj_scan_depth_skip_only_actual_ancestors(self, run_scan): + """Normal: heading depth skip (e.g., * then ***) → chain captures only present headings.""" + content = "* Section\n*** Deep child\n#+begin_src cj: comment\nbody\n#+end_src\n" + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert [h["depth"] for h in chain] == [1, 3] + + def test_cj_scan_shallower_sibling_pops_deeper_frames(self, run_scan): + """Normal: when a shallower heading appears, deeper frames pop off the stack.""" + content = ( + "* A\n** A.1\n*** A.1.1\n" + "** B\n" + "#+begin_src cj: comment\nunder B\n#+end_src\n" + ) + result = run_scan(content) + chain = result["cj_blocks"][0]["parent_heading_chain"] + assert len(chain) == 2 + assert chain[0]["heading"] == "A" + assert chain[1]["heading"] == "B" + + +# ---------------------------------------------------------------------- +# VERIFY task detection + placement audit +# ---------------------------------------------------------------------- + +class TestCjScanVerifyPlacement: + """VERIFY task detection and placement audit per the canonical rule.""" + + def test_cj_scan_verify_at_depth_2_is_valid(self, run_scan): + """Normal: ** VERIFY (top-level) is valid placement.""" + content = "* Work\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n" + result = run_scan(content) + assert len(result["verify_tasks"]) == 1 + v = result["verify_tasks"][0] + assert v["depth"] == 2 + assert v["valid_depth"] is True + assert v["promotion_target"] is None + + def test_cj_scan_verify_at_depth_3_is_valid(self, run_scan): + """Normal: *** VERIFY (first-level child) is valid placement.""" + content = "* Work\n** TODO Parent\n*** VERIFY Question?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 3 + assert v["valid_depth"] is True + + def test_cj_scan_verify_at_depth_4_invalid_promote_to_3(self, run_scan): + """Normal: **** VERIFY is buried; suggests promotion to depth 3.""" + content = "* W\n** P\n*** Q\n**** VERIFY Buried?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 4 + assert v["valid_depth"] is False + assert v["promotion_target"] == 3 + + def test_cj_scan_verify_at_depth_6_invalid_promote_to_3(self, run_scan): + """Normal: ****** VERIFY at any deep level → promotion target is still 3.""" + content = "* W\n** P\n*** Q\n**** Q2\n***** Q3\n****** VERIFY Very buried?\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 6 + assert v["promotion_target"] == 3 + + def test_cj_scan_verify_at_depth_1_invalid_promote_to_2(self, run_scan): + """Boundary: * VERIFY at top-section depth → promotion target is 2 (top-level under section).""" + content = "* VERIFY Should-be-deeper\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert v["depth"] == 1 + assert v["valid_depth"] is False + assert v["promotion_target"] == 2 + + def test_cj_scan_verify_heading_with_priority_and_tags(self, run_scan): + """Boundary: VERIFY heading with priority cookie + tags → heading text captured fully.""" + content = "* W\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n" + result = run_scan(content) + v = result["verify_tasks"][0] + assert "Hayk's Farearth Evaluation" in v["heading"] + assert ":research:" in v["heading"] + + def test_cj_scan_no_verify_tasks_empty_list(self, run_scan): + """Boundary: file with only TODO/DOING headings → empty verify_tasks list.""" + content = "* W\n** TODO X\n*** DOING Y\n" + result = run_scan(content) + assert result["verify_tasks"] == [] + + def test_cj_scan_verify_word_in_body_is_not_a_task(self, run_scan): + """Error: the word VERIFY appearing in body prose is not detected as a task.""" + content = ( + "* Work\n" + "** TODO Important task\n" + "Body line mentioning VERIFY in prose.\n" + ) + result = run_scan(content) + assert result["verify_tasks"] == [] diff --git a/claude-templates/.ai/scripts/tests/test_cmail_action.py b/claude-templates/.ai/scripts/tests/test_cmail_action.py new file mode 100644 index 0000000..3f77ca3 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cmail_action.py @@ -0,0 +1,669 @@ +"""Tests for cmail-action.py. + +Covers: +- Pure helpers: parse_fetch_metadata, extract_body, _decode_header +- I/O commands: cmd_list_unread, cmd_read, cmd_trash, _store wrappers, + cmd_folders +- Argparse dispatch (subprocess --help) + +Strategy: import the script via importlib.util (filename has a hyphen, +so a regular `import cmail_action` won't work). Patch +cmail_action.connect to return a configured MagicMock IMAP4 instance +for the I/O tests. connect() itself is testability-blocked (network + +SSL + file I/O); manual smoke testing covers it. +""" + +from __future__ import annotations + +import email +import importlib.util +import json +import subprocess +import sys +from email.message import EmailMessage +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.policy import default as default_policy +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "cmail-action.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("cmail_action", str(SCRIPT_PATH)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def cmail_action(): + return _load_module() + + +# --------------------------------------------------------------------------- +# parse_fetch_metadata — pure +# --------------------------------------------------------------------------- + +class TestParseFetchMetadata: + + def test_normal_flags_and_size(self, cmail_action): + meta = "1 (FLAGS (\\Seen) RFC822.SIZE 12345)" + assert cmail_action.parse_fetch_metadata(meta) == { + "flags": "\\Seen", + "size": 12345, + } + + def test_boundary_empty_flags_zero_size(self, cmail_action): + meta = "1 (FLAGS () RFC822.SIZE 0)" + assert cmail_action.parse_fetch_metadata(meta) == { + "flags": "", + "size": 0, + } + + def test_boundary_multiple_flags(self, cmail_action): + meta = "1 (FLAGS (\\Seen \\Flagged \\Recent) RFC822.SIZE 999)" + result = cmail_action.parse_fetch_metadata(meta) + assert result["flags"] == "\\Seen \\Flagged \\Recent" + assert result["size"] == 999 + + def test_boundary_no_size_key(self, cmail_action): + meta = "1 (FLAGS (\\Recent))" + result = cmail_action.parse_fetch_metadata(meta) + assert result["flags"] == "\\Recent" + assert result["size"] is None + + def test_boundary_no_flags_key(self, cmail_action): + meta = "1 (RFC822.SIZE 500)" + result = cmail_action.parse_fetch_metadata(meta) + assert result["flags"] == "" + assert result["size"] == 500 + + def test_boundary_metadata_split_across_chunks_concatenated(self, cmail_action): + # The bug fix that motivated extracting this helper: imaplib returns + # FLAGS / RFC822.SIZE in a non-tuple chunk after the BODY literal + # closes. cmd_list_unread now concatenates all chunks, then + # parse_fetch_metadata sees the combined string. Verify the parser + # handles the combined shape. + combined = ("3315 (BODY[HEADER.FIELDS (FROM TO)] {123}" + " FLAGS () RFC822.SIZE 65546)") + result = cmail_action.parse_fetch_metadata(combined) + assert result["flags"] == "" + assert result["size"] == 65546 + + def test_error_empty_input(self, cmail_action): + assert cmail_action.parse_fetch_metadata("") == {"flags": "", "size": None} + + def test_error_malformed_size_value_does_not_raise(self, cmail_action): + meta = "1 (RFC822.SIZE notanumber)" + result = cmail_action.parse_fetch_metadata(meta) + assert result["size"] is None + + def test_error_unclosed_flags_paren_returns_empty_flags(self, cmail_action): + # Defensive: parser doesn't find a closing paren after FLAGS (, so + # flags stays empty. Size still parses since RFC822.SIZE is found + # via the independent token-scan path. + meta = "1 (FLAGS (\\Seen RFC822.SIZE 100" + result = cmail_action.parse_fetch_metadata(meta) + assert result["flags"] == "" + assert result["size"] == 100 + + +# --------------------------------------------------------------------------- +# extract_body — pure +# --------------------------------------------------------------------------- + +class TestExtractBody: + + @staticmethod + def _multipart_alt(plain="plain text body", html="<p>html body</p>"): + # Build with the legacy MIME* constructors, then round-trip + # through email.message_from_bytes with the default policy so the + # parts are EmailMessage instances with .get_content() — matching + # what cmd_read sees when imaplib hands it RFC822 bytes. + msg = MIMEMultipart("alternative") + if plain is not None: + msg.attach(MIMEText(plain, "plain")) + if html is not None: + msg.attach(MIMEText(html, "html")) + return email.message_from_bytes(msg.as_bytes(), policy=default_policy) + + def test_normal_multipart_prefers_text_plain(self, cmail_action): + msg = self._multipart_alt(plain="plain wins", html="<p>html loses</p>") + assert cmail_action.extract_body(msg) == "plain wins" + + def test_boundary_html_only_multipart_falls_back_to_html(self, cmail_action): + msg = self._multipart_alt(plain=None, html="<p>only html</p>") + result = cmail_action.extract_body(msg) + assert result is not None + assert "only html" in result + + def test_boundary_singlepart_returns_content_directly(self, cmail_action): + msg = EmailMessage() + msg.set_content("single-part body") + # set_content adds Content-Type: text/plain by default; result has + # a trailing newline from the policy formatter. + assert cmail_action.extract_body(msg).strip() == "single-part body" + + def test_error_multipart_with_no_text_parts_returns_none(self, cmail_action): + msg = MIMEMultipart("alternative") + msg.attach(MIMEApplication(b"binary blob")) + # Round-trip for parity with the parser-based path real callers use. + parsed = email.message_from_bytes(msg.as_bytes(), policy=default_policy) + assert cmail_action.extract_body(parsed) is None + + +# --------------------------------------------------------------------------- +# _decode_header — pure +# --------------------------------------------------------------------------- + +class TestDecodeHeader: + + def test_normal_string(self, cmail_action): + assert cmail_action._decode_header("hello") == "hello" + + def test_boundary_empty_string(self, cmail_action): + assert cmail_action._decode_header("") == "" + + def test_boundary_none_returns_empty(self, cmail_action): + assert cmail_action._decode_header(None) == "" + + def test_boundary_non_string_coerced_via_str(self, cmail_action): + assert cmail_action._decode_header(42) == "42" + + +# --------------------------------------------------------------------------- +# Helpers for I/O command tests +# --------------------------------------------------------------------------- + +def _build_fetch_response(uid, from_addr="alice@example.com", subject="Hello", + size=1500): + """Mimic imaplib's FETCH response shape: BODY literal as a tuple, + trailing FLAGS/SIZE/close-paren as a separate bytes chunk. + """ + headers = ( + f"From: {from_addr}\r\n" + f"To: c@cjennings.net\r\n" + f"Subject: {subject}\r\n" + f"Date: Thu, 07 May 2026 12:00:00 -0500\r\n" + ).encode() + return ("OK", [ + (f"{uid} (BODY[HEADER.FIELDS (FROM TO SUBJECT DATE)] " + f"{{{len(headers)}}}".encode(), headers), + f" FLAGS () RFC822.SIZE {size})".encode(), + ]) + + +# --------------------------------------------------------------------------- +# cmd_list_unread — mocked imaplib +# --------------------------------------------------------------------------- + +class TestCmdListUnread: + + def test_normal_three_unread(self, cmail_action, capsys): + fetch_responses = { + b"100": _build_fetch_response("100", "alice@example.com", "Hello", 1500), + b"101": _build_fetch_response("101", "bob@example.com", "Howdy", 2000), + b"102": _build_fetch_response("102", "carol@example.com", "Hi", 500), + } + + def uid_side_effect(cmd, *args): + if cmd == "SEARCH": + return ("OK", [b"100 101 102"]) + if cmd == "FETCH": + return fetch_responses[args[0]] + return ("OK", [b""]) + + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.side_effect = uid_side_effect + + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) + + parsed = json.loads(capsys.readouterr().out) + assert len(parsed) == 3 + assert parsed[0]["uid"] == "100" + assert parsed[0]["from"] == "alice@example.com" + assert parsed[0]["subject"] == "Hello" + assert parsed[0]["size"] == 1500 + assert parsed[2]["uid"] == "102" + + def test_boundary_zero_unread(self, cmail_action, capsys): + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.side_effect = lambda cmd, *a: ("OK", [b""]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) + assert json.loads(capsys.readouterr().out) == [] + + def test_boundary_single_unread(self, cmail_action, capsys): + def uid_se(cmd, *args): + if cmd == "SEARCH": + return ("OK", [b"42"]) + if cmd == "FETCH": + return _build_fetch_response("42", "x@y", "Solo", 100) + return ("OK", [b""]) + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.side_effect = uid_se + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) + parsed = json.loads(capsys.readouterr().out) + assert len(parsed) == 1 + assert parsed[0]["uid"] == "42" + + def test_boundary_limit_truncates_to_most_recent(self, cmail_action, capsys): + # 10 unread, limit=3 — keeps the last 3 (most recent). + all_uids = [str(i).encode() for i in range(100, 110)] + + def uid_se(cmd, *args): + if cmd == "SEARCH": + return ("OK", [b" ".join(all_uids)]) + if cmd == "FETCH": + return _build_fetch_response(args[0].decode(), "x@y", "S", 100) + return ("OK", [b""]) + + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.side_effect = uid_se + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_list_unread(SimpleNamespace(limit=3)) + parsed = json.loads(capsys.readouterr().out) + assert [p["uid"] for p in parsed] == ["107", "108", "109"] + + def test_error_search_returns_no(self, cmail_action): + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.return_value = ("NO", [b"server error"]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + with pytest.raises(SystemExit): + cmail_action.cmd_list_unread(SimpleNamespace(limit=50)) + + +# --------------------------------------------------------------------------- +# cmd_read — mocked imaplib +# --------------------------------------------------------------------------- + +class TestCmdRead: + + @staticmethod + def _rfc822(body="hello world", subject="Test"): + msg = EmailMessage() + msg["From"] = "alice@example.com" + msg["To"] = "c@cjennings.net" + msg["Subject"] = subject + msg["Date"] = "Thu, 07 May 2026 12:00:00 -0500" + msg.set_content(body) + return bytes(msg) + + def test_normal_prints_headers_and_body(self, cmail_action, capsys): + raw = self._rfc822(body="body content here", subject="subj") + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.return_value = ("OK", [(b"1 (RFC822 {N}", raw)]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_read(SimpleNamespace(uid=42)) + out = capsys.readouterr().out + assert "From: alice@example.com" in out + assert "Subject: subj" in out + assert "body content here" in out + + def test_error_uid_not_found(self, cmail_action): + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + # imaplib's shape when the UID has no match: ('OK', [None]) + mock_imap.uid.return_value = ("OK", [None]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + with pytest.raises(SystemExit): + cmail_action.cmd_read(SimpleNamespace(uid=999999)) + + +# --------------------------------------------------------------------------- +# _store wrappers — STORE command shape verification +# --------------------------------------------------------------------------- + +class TestStoreCommands: + + @staticmethod + def _capture_calls(cmail_action, cmd_func, uids, store_typ="OK"): + calls = [] + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + + def uid_se(cmd, uid, op, flags): + calls.append((cmd, op, flags)) + return (store_typ, [b""]) + + mock_imap.uid.side_effect = uid_se + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmd_func(SimpleNamespace(uids=uids)) + return calls + + def test_normal_mark_read_uses_plus_seen(self, cmail_action): + calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_read, [42]) + assert calls == [("STORE", "+FLAGS", r"(\Seen)")] + + def test_normal_mark_unread_uses_minus_seen(self, cmail_action): + calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_unread, [42]) + assert calls == [("STORE", "-FLAGS", r"(\Seen)")] + + def test_normal_star_uses_plus_flagged_and_seen(self, cmail_action): + calls = self._capture_calls(cmail_action, cmail_action.cmd_star, [42]) + assert calls == [("STORE", "+FLAGS", r"(\Flagged \Seen)")] + + def test_normal_unstar_uses_minus_flagged(self, cmail_action): + calls = self._capture_calls(cmail_action, cmail_action.cmd_unstar, [42]) + assert calls == [("STORE", "-FLAGS", r"(\Flagged)")] + + def test_boundary_multi_uid_calls_store_per_uid(self, cmail_action): + calls = self._capture_calls( + cmail_action, cmail_action.cmd_mark_read, [1, 2, 3] + ) + assert len(calls) == 3 + assert all(c == ("STORE", "+FLAGS", r"(\Seen)") for c in calls) + + def test_error_store_failure_raises_systemexit(self, cmail_action): + with pytest.raises(SystemExit): + self._capture_calls( + cmail_action, cmail_action.cmd_mark_read, [42], store_typ="NO" + ) + + +# --------------------------------------------------------------------------- +# cmd_trash — MOVE happy path + COPY+DELETE+EXPUNGE fallback +# --------------------------------------------------------------------------- + +class TestCmdTrash: + + def test_normal_move_succeeds_and_expunges(self, cmail_action): + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.return_value = ("OK", [b""]) + mock_imap.expunge.return_value = ("OK", [b""]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_trash(SimpleNamespace(uids=[100, 101])) + move_calls = [c for c in mock_imap.uid.call_args_list + if c[0][0] == "MOVE"] + assert len(move_calls) == 2 + assert mock_imap.expunge.called + + def test_boundary_move_fails_falls_back_to_copy_then_delete(self, cmail_action): + # MOVE returns NO -> fallback path: COPY, then STORE +FLAGS \Deleted, + # then EXPUNGE. Verify the sequence executes as documented. + seen_cmds = [] + + def uid_se(cmd, *args): + seen_cmds.append(cmd) + if cmd == "MOVE": + return ("NO", [b"not supported"]) + return ("OK", [b""]) + + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.side_effect = uid_se + mock_imap.expunge.return_value = ("OK", [b""]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_trash(SimpleNamespace(uids=[100])) + assert seen_cmds == ["MOVE", "COPY", "STORE"] + assert mock_imap.expunge.called + + def test_error_copy_also_fails(self, cmail_action): + mock_imap = MagicMock() + mock_imap.select.return_value = ("OK", [b""]) + mock_imap.uid.side_effect = lambda cmd, *a: ("NO", [b"both fail"]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + with pytest.raises(SystemExit): + cmail_action.cmd_trash(SimpleNamespace(uids=[100])) + + +# --------------------------------------------------------------------------- +# cmd_folders +# --------------------------------------------------------------------------- + +class TestCmdFolders: + + def test_normal_lists_folders(self, cmail_action, capsys): + mock_imap = MagicMock() + mock_imap.list.return_value = ("OK", [ + b'(\\HasNoChildren) "/" "INBOX"', + b'(\\HasNoChildren \\Trash) "/" "Trash"', + ]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + cmail_action.cmd_folders(SimpleNamespace()) + out = capsys.readouterr().out + assert "INBOX" in out + assert "Trash" in out + + def test_error_list_returns_no(self, cmail_action): + mock_imap = MagicMock() + mock_imap.list.return_value = ("NO", [b"server error"]) + with patch.object(cmail_action, "connect", return_value=mock_imap): + with pytest.raises(SystemExit): + cmail_action.cmd_folders(SimpleNamespace()) + + +# --------------------------------------------------------------------------- +# build_message — pure +# --------------------------------------------------------------------------- + +class TestBuildMessage: + + def test_normal_no_attachments_is_singlepart(self, cmail_action): + msg = cmail_action.build_message( + from_addr="c@cjennings.net", + to_addr="recipient@example.com", + subject="Hello", + body="hello world", + ) + assert msg["From"] == "c@cjennings.net" + assert msg["To"] == "recipient@example.com" + assert msg["Subject"] == "Hello" + assert not msg.is_multipart() + assert msg.get_content().strip() == "hello world" + assert msg.get_content_type() == "text/plain" + + def test_normal_one_attachment_makes_multipart(self, cmail_action): + attachment = ("report.txt", "text", "plain", b"line1\nline2\n") + msg = cmail_action.build_message( + from_addr="c@cjennings.net", + to_addr="recipient@example.com", + subject="With file", + body="see attached", + attachments=[attachment], + ) + assert msg.is_multipart() + # Find the attachment part by Content-Disposition. + attached_parts = [ + p for p in msg.iter_attachments() + if p.get_filename() == "report.txt" + ] + assert len(attached_parts) == 1 + att = attached_parts[0] + assert att.get_content_type() == "text/plain" + assert att.get_content().rstrip("\n") == "line1\nline2" + + def test_boundary_two_attachments(self, cmail_action): + atts = [ + ("a.txt", "text", "plain", b"alpha"), + ("b.bin", "application", "octet-stream", b"\x00\x01\x02"), + ] + msg = cmail_action.build_message( + from_addr="c@cjennings.net", + to_addr="recipient@example.com", + subject="Two files", + body="see attached", + attachments=atts, + ) + names = sorted(p.get_filename() for p in msg.iter_attachments()) + assert names == ["a.txt", "b.bin"] + + def test_boundary_empty_body(self, cmail_action): + msg = cmail_action.build_message( + from_addr="c@cjennings.net", + to_addr="recipient@example.com", + subject="Empty", + body="", + ) + # Body part exists, content is empty (modulo trailing newline). + assert msg.get_content().strip() == "" + + def test_boundary_unicode_preserved_through_serialization(self, cmail_action): + msg = cmail_action.build_message( + from_addr="c@cjennings.net", + to_addr="recipient@example.com", + subject="日本語 ñ ü", + body="café — naïve résumé", + ) + # Round-trip: serialize, parse, check both Subject and body survived. + raw = msg.as_bytes() + parsed = email.message_from_bytes(raw, policy=default_policy) + assert parsed["Subject"] == "日本語 ñ ü" + assert "café" in parsed.get_content() + + +# --------------------------------------------------------------------------- +# load_attachment — file I/O via tmp_path +# --------------------------------------------------------------------------- + +class TestLoadAttachment: + + def test_normal_text_file(self, cmail_action, tmp_path): + p = tmp_path / "notes.txt" + p.write_text("hello\n") + filename, maintype, subtype, content = cmail_action.load_attachment(p) + assert filename == "notes.txt" + assert maintype == "text" + assert subtype == "plain" + assert content == b"hello\n" + + def test_normal_pdf_mime_detected(self, cmail_action, tmp_path): + p = tmp_path / "doc.pdf" + p.write_bytes(b"%PDF-1.4 fake") + filename, maintype, subtype, _ = cmail_action.load_attachment(p) + assert filename == "doc.pdf" + assert (maintype, subtype) == ("application", "pdf") + + def test_boundary_no_extension_falls_back_to_octet_stream(self, cmail_action, tmp_path): + p = tmp_path / "README" + p.write_text("readme content") + filename, maintype, subtype, _ = cmail_action.load_attachment(p) + assert filename == "README" + assert (maintype, subtype) == ("application", "octet-stream") + + def test_boundary_empty_file(self, cmail_action, tmp_path): + p = tmp_path / "empty.txt" + p.write_text("") + _, _, _, content = cmail_action.load_attachment(p) + assert content == b"" + + def test_error_missing_file_raises(self, cmail_action, tmp_path): + p = tmp_path / "does-not-exist.txt" + with pytest.raises(FileNotFoundError): + cmail_action.load_attachment(p) + + def test_error_directory_raises(self, cmail_action, tmp_path): + with pytest.raises(IsADirectoryError): + cmail_action.load_attachment(tmp_path) + + +# --------------------------------------------------------------------------- +# cmd_send — mocked smtp_connect +# --------------------------------------------------------------------------- + +class TestCmdSend: + + @staticmethod + def _args(to="r@example.com", subject="s", body="b", body_file=None, + attach=None, stdin=False): + return SimpleNamespace( + to=to, subject=subject, + body=None if stdin else body, + body_file=body_file, + attach=attach or [], + ) + + def test_normal_inline_body_calls_send_message(self, cmail_action): + mock_smtp = MagicMock() + with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): + cmail_action.cmd_send(self._args( + to="recipient@example.com", + subject="testing cmail action script", + body="lorem ipsum dolor sit amet", + )) + mock_smtp.send_message.assert_called_once() + sent = mock_smtp.send_message.call_args[0][0] + assert sent["To"] == "recipient@example.com" + assert sent["Subject"] == "testing cmail action script" + assert sent["From"] == cmail_action.USER + assert "lorem ipsum dolor sit amet" in sent.get_content() + mock_smtp.quit.assert_called_once() + + def test_boundary_body_from_file(self, cmail_action, tmp_path): + body_file = tmp_path / "body.txt" + body_file.write_text("body from file") + mock_smtp = MagicMock() + with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): + cmail_action.cmd_send(self._args(body=None, body_file=str(body_file))) + sent = mock_smtp.send_message.call_args[0][0] + assert "body from file" in sent.get_content() + + def test_boundary_with_attachment(self, cmail_action, tmp_path): + att = tmp_path / "report.txt" + att.write_text("attachment content") + mock_smtp = MagicMock() + with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): + cmail_action.cmd_send(self._args(attach=[str(att)])) + sent = mock_smtp.send_message.call_args[0][0] + assert sent.is_multipart() + atts = list(sent.iter_attachments()) + assert len(atts) == 1 + assert atts[0].get_filename() == "report.txt" + assert atts[0].get_content().rstrip("\n") == "attachment content" + + def test_error_missing_attachment_exits_before_smtp(self, cmail_action, tmp_path): + # Attachment files are validated first; SMTP is never opened on failure. + mock_smtp = MagicMock() + with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): + with pytest.raises((SystemExit, FileNotFoundError)): + cmail_action.cmd_send(self._args( + attach=[str(tmp_path / "does-not-exist.txt")] + )) + mock_smtp.send_message.assert_not_called() + + def test_error_smtp_send_failure_raises(self, cmail_action): + import smtplib + mock_smtp = MagicMock() + mock_smtp.send_message.side_effect = smtplib.SMTPException("boom") + with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp): + with pytest.raises((SystemExit, smtplib.SMTPException)): + cmail_action.cmd_send(self._args()) + + +# --------------------------------------------------------------------------- +# Argparse — black-box subprocess sanity check +# --------------------------------------------------------------------------- + +class TestArgparseShape: + + def test_normal_help_lists_all_subcommands(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + for sub in ("list-unread", "read", "mark-read", "mark-unread", + "star", "unstar", "trash", "folders", "send"): + assert sub in result.stdout + + def test_error_no_subcommand_exits_nonzero(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH)], + capture_output=True, text=True, + ) + assert result.returncode != 0 diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py b/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py new file mode 100644 index 0000000..f0d2bb7 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py @@ -0,0 +1,204 @@ +"""Tests for cross-agent-discover (TDD: tests written before implementation).""" + +from __future__ import annotations + +import json +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-discover" + + +def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + return home + + +def _make_project(home: Path, name: str) -> Path: + proj = home / "projects" / name + (proj / ".ai").mkdir(parents=True) + return proj + + +def _write_peers_toml(home: Path, content: str) -> Path: + cfg = home / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True, exist_ok=True) + peers = cfg / "peers.toml" + peers.write_text(content) + return peers + + +def test_discover_help(fake_home): + result = _run(["--help"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "discover" in result.stdout.lower() or "enumerate" in result.stdout.lower() + + +def test_discover_local_only_no_projects(fake_home): + """Empty home → reports zero local projects, zero peers.""" + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + # No crash; mentions local somehow. + assert "local" in result.stdout.lower() or "0 project" in result.stdout.lower() + + +def test_discover_lists_local_projects(fake_home): + _make_project(fake_home, "homelab") + _make_project(fake_home, "career") + _make_project(fake_home, "claude-templates") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "homelab" in result.stdout + assert "career" in result.stdout + assert "claude-templates" in result.stdout + + +def test_discover_excludes_dirs_without_ai_subdir(fake_home): + """Directories under ~/projects/ that lack .ai/ are NOT projects.""" + _make_project(fake_home, "real-project") + (fake_home / "projects" / "not-a-project").mkdir(parents=True) + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "real-project" in result.stdout + assert "not-a-project" not in result.stdout + + +def test_discover_no_peers_toml_just_local(fake_home): + _make_project(fake_home, "homelab") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + # No peers section since no toml. + assert "homelab" in result.stdout + + +def test_discover_lists_peers_from_toml(fake_home): + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.velox] + host = "velox" + ssh_user = "cjennings" + + [peers.bastion] + host = "bastion.local" + ssh_user = "cjennings" + """)) + _make_project(fake_home, "homelab") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + assert "velox" in result.stdout + assert "bastion" in result.stdout + + +def test_discover_malformed_peers_toml_errors_clearly(fake_home): + _write_peers_toml(fake_home, "not valid toml at all = = =") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode != 0 + assert "peers.toml" in result.stderr or "TOML" in result.stderr or "parse" in result.stderr.lower() + + +def test_discover_json_output_schema(fake_home): + _make_project(fake_home, "homelab") + _make_project(fake_home, "career") + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.velox] + host = "velox" + """)) + result = _run(["--json", "--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert "local" in payload + assert "peers" in payload + assert isinstance(payload["local"], list) + assert isinstance(payload["peers"], list) + assert "homelab" in payload["local"] + assert "career" in payload["local"] + velox = next((p for p in payload["peers"] if p["name"] == "velox"), None) + assert velox is not None + # Reachability is a key — value depends on actual SSH state. + assert "reachable" in velox + + +def test_discover_peer_scope(fake_home): + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.velox] + host = "velox" + + [peers.bastion] + host = "bastion.local" + """)) + result = _run(["--peer", "velox", "--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + peer_names = [p["name"] for p in payload["peers"]] + assert "velox" in peer_names + assert "bastion" not in peer_names + + +def test_discover_unreachable_peer_marked(fake_home): + """A peer with a definitely-unreachable host gets reachable=False.""" + _write_peers_toml(fake_home, textwrap.dedent("""\ + [peers.bogus] + host = "definitely-not-a-real-host.invalid" + ssh_user = "nobody" + """)) + result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}, ) + assert result.returncode == 0 + payload = json.loads(result.stdout) + bogus = next((p for p in payload["peers"] if p["name"] == "bogus"), None) + assert bogus is not None + assert bogus["reachable"] is False + + +def test_discover_cache_hit_within_window(fake_home): + """Second invocation within 5 min reads cache (skip the SSH probe).""" + _make_project(fake_home, "homelab") + # First call populates cache. + result1 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result1.returncode == 0 + cache = fake_home / ".cache" / "cross-agent-comms" / "discovery.json" + assert cache.exists() + # Tamper with the cache to a marker only the cache path can produce. + payload = json.loads(cache.read_text()) + payload["_test_marker"] = True + cache.write_text(json.dumps(payload)) + # Second call (no --no-cache) should return the tampered payload. + result2 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result2.returncode == 0 + payload2 = json.loads(result2.stdout) + assert payload2.get("_test_marker") is True + + +def test_discover_no_cache_flag_bypasses(fake_home): + """--no-cache ignores even a fresh cache.""" + _make_project(fake_home, "homelab") + cache_dir = fake_home / ".cache" / "cross-agent-comms" + cache_dir.mkdir(parents=True) + cache_dir.joinpath("discovery.json").write_text(json.dumps({ + "_test_marker": True, "local": [], "peers": [] + })) + result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + # Cache marker should NOT appear in fresh result. + assert payload.get("_test_marker") is None or payload.get("_test_marker") is False + assert "homelab" in payload["local"] + + +def test_discover_halt_shows_banner(fake_home): + halt = fake_home / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted") + _make_project(fake_home, "homelab") + result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)}) + assert result.returncode == 0 # discover continues to print under HALT + assert "HALT" in result.stdout diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py b/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py new file mode 100644 index 0000000..f8bf0b3 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py @@ -0,0 +1,204 @@ +"""Tests for cross-agent-halt and cross-agent-resume (TDD).""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +HALT_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-halt" +RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-resume" + + +def _run(script: Path, args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(script), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + """Isolated HOME + a fake systemctl that records calls without acting.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + # Fake systemctl: no-op, exit 0. + fake_systemctl = fake_bin / "systemctl" + fake_systemctl.write_text("#!/usr/bin/env bash\nexit 0\n") + fake_systemctl.chmod(0o755) + # Fake ssh: succeed only for known-good host. + fake_ssh = fake_bin / "ssh" + fake_ssh.write_text(textwrap.dedent("""\ + #!/usr/bin/env bash + # Find the destination arg (skip flags). + target="" + for arg in "$@"; do + case "$arg" in + -*|*=*) ;; + *@*|localhost|*.local|*.invalid) target="$arg"; break ;; + *) target="$arg"; break ;; + esac + done + case "$target" in + *invalid*|*unreachable*) exit 255 ;; + *) exit 0 ;; + esac + """)) + fake_ssh.chmod(0o755) + + monkeypatch.setenv("HOME", str(fake_home)) + # Prepend our fake bin so systemctl + ssh are intercepted, but keep real /bin etc. + monkeypatch.setenv("PATH", f"{fake_bin}:{os.environ.get('PATH', '')}") + return fake_home + + +# ---- cross-agent-halt ---- + + +def test_halt_help(isolated_env): + result = _run(HALT_SCRIPT, ["--help"], env={**os.environ, "HOME": str(isolated_env), + "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "halt" in result.stdout.lower() + + +def test_halt_creates_halt_file(isolated_env): + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + assert not halt_file.exists() + result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), + "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert halt_file.exists() + + +def test_halt_with_reason_writes_body(isolated_env): + result = _run(HALT_SCRIPT, ["pausing for incident review"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + assert halt_file.exists() + assert "pausing for incident review" in halt_file.read_text() + + +def test_halt_idempotent(isolated_env): + """Running halt twice doesn't error.""" + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + r1 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert r1.returncode == 0 + assert halt_file.exists() + r2 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert r2.returncode == 0 + assert halt_file.exists() + + +def test_halt_does_not_pkill(isolated_env): + """Per design: halt does NOT call pkill. Verify by checking no pkill process gets launched.""" + # Replace pkill in PATH with something that fails loudly so we'd see if halt invoked it. + fake_bin = isolated_env.parent / "bin" + pkill = fake_bin / "pkill" + pkill.write_text("#!/usr/bin/env bash\necho 'PKILL CALLED' >&2\nexit 99\n") + pkill.chmod(0o755) + result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "PKILL CALLED" not in result.stderr + + +def test_halt_tailnet_reports_per_peer(isolated_env): + """--tailnet iterates peers.toml and reports per-peer status.""" + cfg = isolated_env / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True) + (cfg / "peers.toml").write_text(textwrap.dedent("""\ + [peers.velox] + host = "velox" + ssh_user = "cjennings" + + [peers.bogus] + host = "definitely-unreachable.invalid" + ssh_user = "cjennings" + """)) + result = _run(HALT_SCRIPT, ["--tailnet"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + # Partial halt → exit 1. + assert result.returncode == 1 + assert "velox" in result.stdout + assert "bogus" in result.stdout + # ✓ marker for velox, ✗ for bogus. + assert "✓" in result.stdout + assert "✗" in result.stdout + assert "PARTIAL" in result.stdout or "partial" in result.stdout.lower() + + +def test_halt_tailnet_all_reachable_exits_zero(isolated_env): + cfg = isolated_env / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True) + (cfg / "peers.toml").write_text(textwrap.dedent("""\ + [peers.velox] + host = "velox" + ssh_user = "cjennings" + """)) + result = _run(HALT_SCRIPT, ["--tailnet"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "velox" in result.stdout + + +# ---- cross-agent-resume ---- + + +def test_resume_help(isolated_env): + result = _run(RESUME_SCRIPT, ["--help"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert "resume" in result.stdout.lower() + + +def test_resume_removes_halt_file(isolated_env): + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt_file.parent.mkdir(parents=True) + halt_file.write_text("halted") + assert halt_file.exists() + result = _run(RESUME_SCRIPT, [], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + assert not halt_file.exists() + + +def test_resume_when_no_halt_active_succeeds(isolated_env): + """No HALT to clear is not an error.""" + result = _run(RESUME_SCRIPT, [], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + + +def test_resume_prints_per_session_instructions(isolated_env): + """Resume must surface that polling does NOT auto-resume.""" + halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt_file.parent.mkdir(parents=True) + halt_file.write_text("halted") + result = _run(RESUME_SCRIPT, [], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 0 + out = result.stdout.lower() + assert "polling" in out + assert "auto" in out or "explicit" in out or "session" in out + + +def test_resume_tailnet_partial_failure_exit_1(isolated_env): + cfg = isolated_env / ".config" / "cross-agent-comms" + cfg.mkdir(parents=True) + (cfg / "peers.toml").write_text(textwrap.dedent("""\ + [peers.velox] + host = "velox" + + [peers.bogus] + host = "unreachable-host.invalid" + """)) + halt_file = cfg / "HALT" + halt_file.write_text("halted") + result = _run(RESUME_SCRIPT, ["--tailnet"], + env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]}) + assert result.returncode == 1 + assert "velox" in result.stdout + assert "bogus" in result.stdout diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py b/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py new file mode 100644 index 0000000..27c53a5 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py @@ -0,0 +1,176 @@ +"""Tests for cross-agent-recv.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-recv" + + +def _make_message(path: Path, *, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request", + proto_version: str = "5", title: str = "Test", requires_tools: str | None = None, + body: str = "Body.\n") -> Path: + fm_lines = [ + f"#+TITLE: {title}", + f"#+CONVERSATION_ID: {conv_id}", + f"#+MESSAGE_TYPE: {msg_type}", + f"#+SEQUENCE: {seq}", + "#+TIMESTAMP: 2026-04-27T05:00:00-05:00", + f"#+PROTOCOL_VERSION: {proto_version}", + ] + if requires_tools: + fm_lines.append(f"#+REQUIRES_TOOLS: {requires_tools}") + path.write_text("\n".join(fm_lines) + "\n\n" + body) + return path + + +def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + return fake_home + + +def test_recv_help(isolated_env): + result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + assert "Receive and decide" in result.stdout + + +def test_recv_missing_file_rejects(isolated_env, tmp_path): + result = _run([str(tmp_path / "nope.org")], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 # reject + + +def test_recv_malformed_frontmatter_rejects(isolated_env, tmp_path): + bad = tmp_path / "bad.org" + bad.write_text("not org-mode at all\n") + result = _run([str(bad), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "decision: reject" in result.stdout + + +def test_recv_missing_required_field_rejects(isolated_env, tmp_path): + msg = tmp_path / "msg.org" + # Missing PROTOCOL_VERSION among others. + msg.write_text("#+TITLE: x\n#+CONVERSATION_ID: c\n\nBody.\n") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "missing required" in result.stdout + + +def test_recv_protocol_version_mismatch_query(isolated_env, tmp_path): + msg = _make_message(tmp_path / "msg.org", proto_version="4") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 2 # query + assert "PROTOCOL_VERSION mismatch" in result.stdout + + +def test_recv_invalid_message_type_rejects(isolated_env, tmp_path): + msg = _make_message(tmp_path / "msg.org", msg_type="banana") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "invalid MESSAGE_TYPE" in result.stdout + + +def test_recv_missing_signature_rejects(isolated_env, tmp_path): + """When verify is on, a missing .asc sibling rejects.""" + msg = _make_message(tmp_path / "msg.org") + # No .asc sidecar. + result = _run([str(msg)], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 3 + assert "signature file missing" in result.stdout + + +def test_recv_valid_processes(isolated_env, tmp_path): + """A valid message with --no-verify and no dedup match → process.""" + msg = _make_message(tmp_path / "msg.org") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 # process + assert "decision: process" in result.stdout + assert "sha256:" in result.stdout + + +def test_recv_dedup_against_identical_existing(isolated_env, tmp_path): + """Same content + same SEQUENCE in same dir → dedup.""" + inbox = tmp_path / "inbox" + inbox.mkdir() + first = _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5) + # Second message with same content — name differs (canonical-style would have different timestamp). + second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5) + # Bodies must be byte-identical for hash equality. + second.write_bytes(first.read_bytes()) + result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 1 # dedup + assert "decision: dedup" in result.stdout + + +def test_recv_collision_with_different_content_processes(isolated_env, tmp_path): + """Same SEQUENCE + same CONVERSATION_ID but different content → process both.""" + inbox = tmp_path / "inbox" + inbox.mkdir() + _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5, body="First body.\n") + second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5, body="Different body.\n") + result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 # process + assert "decision: process" in result.stdout + + +def test_recv_requires_tools_missing_query(isolated_env, tmp_path): + """REQUIRES_TOOLS naming a definitely-missing binary → query.""" + msg = _make_message(tmp_path / "msg.org", requires_tools="definitely-not-installed-xyzzy-9000") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 2 # query + assert "required tools unavailable" in result.stdout + + +def test_recv_requires_tools_present_processes(isolated_env, tmp_path): + """REQUIRES_TOOLS naming a real binary → process.""" + msg = _make_message(tmp_path / "msg.org", requires_tools="ls,cat") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + assert "decision: process" in result.stdout + + +def test_recv_json_output(isolated_env, tmp_path): + msg = _make_message(tmp_path / "msg.org") + result = _run([str(msg), "--no-verify", "--json"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload["decision"] == "process" + assert payload["message_type"] == "request" + assert payload["conversation_id"] == "test-conv" + + +def test_recv_halt_blocks(isolated_env, tmp_path): + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted\n") + msg = _make_message(tmp_path / "msg.org") + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 5 + assert "halt active" in result.stderr.lower() + + +def test_recv_halt_leaves_message_in_place(isolated_env, tmp_path): + """Per spec: under HALT, recv must NOT move/dedup/reject — leave file in place.""" + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted\n") + msg = _make_message(tmp_path / "msg.org") + pre_content = msg.read_text() + result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 5 + # File still exists with same content. + assert msg.exists() + assert msg.read_text() == pre_content diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_send.py b/claude-templates/.ai/scripts/tests/test_cross_agent_send.py new file mode 100644 index 0000000..f716e95 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cross_agent_send.py @@ -0,0 +1,210 @@ +"""Tests for cross-agent-send. + +Subprocess-based: treat the script as a black-box CLI and assert on its +exit codes, stdout, and the files it produces. +""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-send" + + +def _make_message(tmp_path: Path, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request", + proto_version: str = "5") -> Path: + msg = tmp_path / "msg.org" + msg.write_text(textwrap.dedent(f"""\ + #+TITLE: Test message + #+CONVERSATION_ID: {conv_id} + #+MESSAGE_TYPE: {msg_type} + #+SEQUENCE: {seq} + #+TIMESTAMP: 2026-04-27T05:00:00-05:00 + #+PROTOCOL_VERSION: {proto_version} + + Body. + """)) + return msg + + +def _run(args: list[str], env: dict | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + [str(SCRIPT), *args], + capture_output=True, + text=True, + env=env, + cwd=cwd, + ) + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + """Redirect HOME so peers.toml, HALT, marker files are scoped to the test.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + # Pre-create projects/ so derive_sender_project has somewhere to look. + (fake_home / "projects" / "homelab").mkdir(parents=True) + return fake_home + + +def test_send_help(isolated_env): + """--help works without side effects.""" + result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)}) + assert result.returncode == 0 + assert "Send a cross-agent message" in result.stdout + + +def test_send_missing_message_file(isolated_env): + """Nonexistent message file returns general error.""" + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(isolated_env / "nonexistent.org")], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 1 + assert "not found" in result.stderr.lower() + + +def test_send_invalid_destination_format(isolated_env, tmp_path): + """Destination without . returns dest-not-found exit code.""" + msg = _make_message(tmp_path) + result = _run( + ["bogus", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 2 + assert "<machine>.<project>" in result.stderr or "destination" in result.stderr.lower() + + +def test_send_dest_not_in_peers(isolated_env, tmp_path): + """Cross-machine destination with no peers.toml entry exits 2.""" + msg = _make_message(tmp_path) + result = _run( + ["unknownmachine.homelab", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 2 + assert "not found in peers" in result.stderr + + +def test_send_frontmatter_missing_required(isolated_env, tmp_path): + """Message missing required fields exits 4.""" + bad = tmp_path / "bad.org" + bad.write_text("#+TITLE: nope\n\nBody.\n") + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(bad)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 4 + assert "missing required fields" in result.stderr + + +def test_send_invalid_message_type(isolated_env, tmp_path): + """Unknown MESSAGE_TYPE exits 4.""" + msg = _make_message(tmp_path, msg_type="frobnicate") + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 4 + assert "MESSAGE_TYPE" in result.stderr + + +def test_send_halt_blocks(isolated_env, tmp_path): + """When HALT exists, send refuses with exit 5.""" + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("test halt\n") + msg = _make_message(tmp_path) + import socket + machine = socket.gethostname().split(".")[0] + result = _run( + [f"{machine}.homelab", str(msg)], + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 5 + assert "halt active" in result.stderr.lower() + + +def test_send_same_machine_no_sign_delivers(isolated_env, tmp_path): + """Same-machine delivery with --no-sign produces a canonically named file.""" + msg = _make_message(tmp_path, conv_id="my-conv") + import socket + machine = socket.gethostname().split(".")[0] + # Sender is derived from CWD walking up to ~/projects/<name>/ + cwd = isolated_env / "projects" / "homelab" + result = _run( + [f"{machine}.homelab", str(msg), "--no-sign"], + env={**os.environ, "HOME": str(isolated_env)}, + cwd=cwd, + ) + assert result.returncode == 0, f"stderr={result.stderr}" + inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" + files = list(inbox.glob("*-from-homelab-my-conv.org")) + assert len(files) == 1 + # No sig file with --no-sign. + assert not list(inbox.glob("*.asc")) + # Canonical filename pattern. + assert files[0].name.startswith("2026") and files[0].name.endswith("-from-homelab-my-conv.org") + + +def test_send_same_machine_signed_writes_asc(isolated_env, tmp_path): + """Signed delivery writes both .org and .asc.""" + msg = _make_message(tmp_path, conv_id="signed-conv") + import socket + machine = socket.gethostname().split(".")[0] + cwd = isolated_env / "projects" / "homelab" + # Use the real GPG keyring (not isolating GPG — Craig's existing keys are fine for tests). + real_env = {**os.environ, "HOME": str(isolated_env), "GNUPGHOME": str(Path.home() / ".gnupg")} + result = _run( + [f"{machine}.homelab", str(msg)], + env=real_env, + cwd=cwd, + ) + if result.returncode != 0: + pytest.skip(f"GPG signing unavailable in this environment: {result.stderr}") + inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" + org_files = list(inbox.glob("*-from-homelab-signed-conv.org")) + asc_files = list(inbox.glob("*-from-homelab-signed-conv.org.asc")) + assert len(org_files) == 1 + assert len(asc_files) == 1 + + +def test_send_filename_ignores_input_basename(isolated_env, tmp_path): + """User's input filename is ignored; canonical filename is generated.""" + weird = tmp_path / "weird-user-name.org" + weird.write_text(textwrap.dedent("""\ + #+TITLE: Title + #+CONVERSATION_ID: ignored-input + #+MESSAGE_TYPE: request + #+SEQUENCE: 1 + #+TIMESTAMP: 2026-04-27T05:00:00-05:00 + #+PROTOCOL_VERSION: 5 + + Body. + """)) + import socket + machine = socket.gethostname().split(".")[0] + cwd = isolated_env / "projects" / "homelab" + result = _run( + [f"{machine}.homelab", str(weird), "--no-sign"], + env={**os.environ, "HOME": str(isolated_env)}, + cwd=cwd, + ) + assert result.returncode == 0 + inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents" + # No file named after the user's input. + assert not (inbox / "weird-user-name.org").exists() + # Canonical naming used. + assert list(inbox.glob("*-from-homelab-ignored-input.org")) diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_status.py b/claude-templates/.ai/scripts/tests/test_cross_agent_status.py new file mode 100644 index 0000000..bb5b8ba --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cross_agent_status.py @@ -0,0 +1,165 @@ +"""Tests for cross-agent-status (TDD: tests written before implementation).""" + +from __future__ import annotations + +import json +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-status" + + +def _make_msg(path: Path, *, conv_id: str, seq: int, msg_type: str = "request", + proto_version: str = "5", timestamp: str = "2026-04-27T05:00:00-05:00") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(f"""\ + #+TITLE: T + #+CONVERSATION_ID: {conv_id} + #+MESSAGE_TYPE: {msg_type} + #+SEQUENCE: {seq} + #+TIMESTAMP: {timestamp} + #+PROTOCOL_VERSION: {proto_version} + + Body. + """)) + return path + + +def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env) + + +@pytest.fixture +def fake_projects(tmp_path, monkeypatch): + """Create a fake ~/projects/<name>/inbox/from-agents/ tree under tmp_path.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + return home + + +def test_status_help(fake_projects): + result = _run(["--help"], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + assert "snapshot" in result.stdout.lower() or "pending" in result.stdout.lower() + + +def test_status_no_projects_clean_output(fake_projects): + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + # Empty machine prints either header-only table or "no projects" — accept either. + # No crash, no pending claims. + assert "pending" in result.stdout.lower() or result.stdout.strip() == "" + + +def test_status_one_pending_shows_up(fake_projects): + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-fixup.org", conv_id="fixup", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + assert "homelab" in result.stdout + assert "1" in result.stdout # pending count + assert "20260427T100000Z-from-career-fixup.org" in result.stdout + + +def test_status_released_conversation_zero_pending(fake_projects): + """A conversation with a release message in it counts as 0 pending.""" + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-done.org", conv_id="done", seq=1) + _make_msg(inbox / "20260427T100100Z-from-homelab-done.org", conv_id="done", seq=2, msg_type="release") + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + # Check the homelab row shows 0 pending. + lines = [ln for ln in result.stdout.splitlines() if "homelab" in ln] + # At least one homelab line should show 0 pending or "—". + assert any("0" in ln or "—" in ln for ln in lines) + + +def test_status_partial_release(fake_projects): + """Conversation with release + a later message → that later message counts as pending.""" + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-x.org", conv_id="x", seq=1, + timestamp="2026-04-27T05:00:00-05:00") + _make_msg(inbox / "20260427T100100Z-from-homelab-x.org", conv_id="x", seq=2, msg_type="release", + timestamp="2026-04-27T05:01:00-05:00") + # New message AFTER release: starts a fresh thread that's pending. + _make_msg(inbox / "20260427T200000Z-from-career-x.org", conv_id="x", seq=3, + timestamp="2026-04-27T15:00:00-05:00") + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln) + assert "1" in homelab_line # the post-release message is pending + + +def test_status_multiple_projects(fake_projects): + inbox_a = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + inbox_b = fake_projects / "projects" / "career" / "inbox" / "from-agents" + _make_msg(inbox_a / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1) + _make_msg(inbox_b / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1) + _make_msg(inbox_b / "20260427T100200Z-from-x-c.org", conv_id="c", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + # career has 2 pending, homelab has 1. + career_line = next(ln for ln in result.stdout.splitlines() if "career" in ln) + homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln) + assert "2" in career_line + assert "1" in homelab_line + + +def test_status_json_output(fake_projects): + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-career-test.org", conv_id="test", seq=1) + result = _run(["--json"], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert "projects" in payload + assert isinstance(payload["projects"], list) + homelab = next((p for p in payload["projects"] if p["name"] == "homelab"), None) + assert homelab is not None + assert homelab["pending_count"] == 1 + + +def test_status_sort_pending_first(fake_projects): + """Projects with pending messages sort before projects with 0.""" + (fake_projects / "projects" / "alpha" / "inbox" / "from-agents").mkdir(parents=True) + inbox_zeta = fake_projects / "projects" / "zeta" / "inbox" / "from-agents" + _make_msg(inbox_zeta / "20260427T100000Z-from-x-z.org", conv_id="z", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 + lines = result.stdout.splitlines() + zeta_idx = next(i for i, ln in enumerate(lines) if "zeta" in ln) + alpha_idx = next(i for i, ln in enumerate(lines) if "alpha" in ln) + assert zeta_idx < alpha_idx, "pending project should sort before zero-pending project" + + +def test_status_halt_shows_banner(fake_projects): + halt = fake_projects / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted for test") + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-x-x.org", conv_id="x", seq=1) + result = _run([], env={**os.environ, "HOME": str(fake_projects)}) + assert result.returncode == 0 # status continues to print under HALT + assert "HALT" in result.stdout + # Banner should mention the reason. + assert "halted for test" in result.stdout + + +def test_status_projects_glob_override(fake_projects): + inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents" + _make_msg(inbox / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1) + other_inbox = fake_projects / "projects" / "career" / "inbox" / "from-agents" + _make_msg(other_inbox / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1) + # Glob limits to homelab only. + result = _run( + ["--projects-glob", str(fake_projects / "projects" / "homelab" / "inbox" / "from-agents") + "/"], + env={**os.environ, "HOME": str(fake_projects)}, + ) + assert result.returncode == 0 + assert "homelab" in result.stdout + # career not in scope. + assert "career" not in result.stdout diff --git a/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py b/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py new file mode 100644 index 0000000..417cc19 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py @@ -0,0 +1,155 @@ +"""Tests for cross-agent-watch. + +Black-box: spawn the script, drop files into a watched dir, read the log. +Tests use --no-notify to avoid firing real desktop notifications. +""" + +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-watch" + + +def _spawn(watched_dir: Path, log_path: Path, env: dict) -> subprocess.Popen: + return subprocess.Popen( + [ + str(SCRIPT), + "--projects-glob", str(watched_dir) + "/", + "--log", str(log_path), + "--no-notify", + "--quiet", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + env=env, + ) + + +def _wait_for_log_lines(log_path: Path, expected: int, timeout: float = 5.0) -> list[str]: + deadline = time.time() + timeout + while time.time() < deadline: + if log_path.exists(): + lines = [ln for ln in log_path.read_text().splitlines() if ln] + if len(lines) >= expected: + return lines + time.sleep(0.1) + if log_path.exists(): + return [ln for ln in log_path.read_text().splitlines() if ln] + return [] + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + return fake_home + + +def test_watch_help(isolated_env): + result = subprocess.run( + [str(SCRIPT), "--help"], + capture_output=True, text=True, + env={**os.environ, "HOME": str(isolated_env)}, + ) + assert result.returncode == 0 + assert "Usage:" in result.stdout + + +def test_watch_empty_glob_exits_nonzero(isolated_env): + """Glob resolving to zero dirs should exit non-zero with a clear message.""" + result = subprocess.run( + [str(SCRIPT), "--projects-glob", "/nonexistent/path/*/foo/", "--no-notify", "--quiet"], + capture_output=True, text=True, + env={**os.environ, "HOME": str(isolated_env)}, + timeout=3, + ) + assert result.returncode != 0 + assert "0 directories" in result.stderr + + +def test_watch_logs_org_file_create(isolated_env, tmp_path): + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + # Give inotifywait a moment to attach. + time.sleep(0.3) + (watched / "test-msg.org").write_text("hello") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert len(lines) >= 1 + assert "test-msg.org" in lines[-1] + finally: + proc.terminate() + proc.wait(timeout=2) + + +def test_watch_filters_tmp_files(isolated_env, tmp_path): + """Files starting with .tmp. must NOT trigger log entries.""" + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + time.sleep(0.3) + (watched / ".tmp.staging-file.org").write_text("hello") + # Wait briefly to confirm nothing logs. + time.sleep(0.5) + if log.exists(): + content = log.read_text() + assert ".tmp.staging-file" not in content + # Then drop a real file to confirm watcher is alive. + (watched / "real.org").write_text("real") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert any("real.org" in ln for ln in lines) + finally: + proc.terminate() + proc.wait(timeout=2) + + +def test_watch_filters_asc_sidecars(isolated_env, tmp_path): + """Only .org events fire; .asc sidecars are silent.""" + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + time.sleep(0.3) + (watched / "msg.org.asc").write_text("sig") + time.sleep(0.5) + if log.exists(): + assert "msg.org.asc" not in log.read_text() + # .org event still works. + (watched / "msg.org").write_text("body") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert any(ln.endswith("msg.org") for ln in lines) + finally: + proc.terminate() + proc.wait(timeout=2) + + +def test_watch_halt_suppresses_but_logs(isolated_env, tmp_path): + """When HALT is set, watcher logs the event with (suppressed by HALT) marker.""" + halt = isolated_env / ".config" / "cross-agent-comms" / "HALT" + halt.parent.mkdir(parents=True) + halt.write_text("halted") + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "watch.log" + proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)}) + try: + time.sleep(0.3) + (watched / "halted-event.org").write_text("body") + lines = _wait_for_log_lines(log, expected=1, timeout=3.0) + assert len(lines) >= 1 + assert "suppressed by HALT" in lines[-1] + finally: + proc.terminate() + proc.wait(timeout=2) diff --git a/claude-templates/.ai/scripts/tests/test_extract_body.py b/claude-templates/.ai/scripts/tests/test_extract_body.py new file mode 100644 index 0000000..7b53cda --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_extract_body.py @@ -0,0 +1,96 @@ +"""Tests for extract_body().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, make_html_message, make_message_with_attachment +from email.message import EmailMessage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +extract_body = eml_script.extract_body + + +class TestPlainText: + def test_returns_plain_text(self): + msg = make_plain_message(body="Hello, this is plain text.") + result = extract_body(msg) + assert "Hello, this is plain text." in result + + +class TestHtmlOnly: + def test_returns_converted_html(self): + msg = make_html_message(html_body="<p>Hello <strong>world</strong></p>") + result = extract_body(msg) + assert "Hello" in result + assert "world" in result + # Should not contain raw HTML tags + assert "<p>" not in result + assert "<strong>" not in result + + +class TestBothPlainAndHtml: + def test_prefers_plain_text(self): + msg = MIMEMultipart('alternative') + msg['From'] = 'test@example.com' + msg['To'] = 'dest@example.com' + msg['Subject'] = 'Test' + msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600' + msg.attach(MIMEText("Plain text version", 'plain')) + msg.attach(MIMEText("<p>HTML version</p>", 'html')) + result = extract_body(msg) + assert "Plain text version" in result + assert "HTML version" not in result + + +class TestEmptyBody: + def test_returns_empty_string(self): + # Multipart with only attachments, no text parts + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + att = MIMEApplication(b"binary data", Name="file.bin") + att['Content-Disposition'] = 'attachment; filename="file.bin"' + msg.attach(att) + result = extract_body(msg) + assert result == "" + + +class TestNonUtf8Encoding: + def test_decodes_with_errors_ignore(self): + msg = EmailMessage() + msg['From'] = 'test@example.com' + # Set raw bytes that include invalid UTF-8 + msg.set_content("Valid text with special: café") + result = extract_body(msg) + assert "Valid text" in result + + +class TestHtmlWithStructure: + def test_preserves_list_structure(self): + html = "<ul><li>Item one</li><li>Item two</li></ul>" + msg = make_html_message(html_body=html) + result = extract_body(msg) + assert "Item one" in result + assert "Item two" in result + + +class TestNoTextParts: + def test_returns_empty_string(self): + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + att = MIMEApplication(b"data", Name="image.png") + att['Content-Disposition'] = 'attachment; filename="image.png"' + msg.attach(att) + result = extract_body(msg) + assert result == "" diff --git a/claude-templates/.ai/scripts/tests/test_extract_metadata.py b/claude-templates/.ai/scripts/tests/test_extract_metadata.py new file mode 100644 index 0000000..d5ee52e --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_extract_metadata.py @@ -0,0 +1,65 @@ +"""Tests for extract_metadata().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, add_received_headers +from email.message import EmailMessage + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +extract_metadata = eml_script.extract_metadata + + +class TestAllHeadersPresent: + def test_complete_dict(self): + msg = make_plain_message( + from_="Jonathan Smith <jsmith@example.com>", + to="Craig <craig@example.com>", + subject="Test Subject", + date="Thu, 05 Feb 2026 11:36:00 -0600" + ) + result = extract_metadata(msg) + assert result['from'] == "Jonathan Smith <jsmith@example.com>" + assert result['to'] == "Craig <craig@example.com>" + assert result['subject'] == "Test Subject" + assert result['date'] == "Thu, 05 Feb 2026 11:36:00 -0600" + assert 'timing' in result + + +class TestMissingFrom: + def test_from_is_none(self): + msg = EmailMessage() + msg['To'] = 'craig@example.com' + msg['Subject'] = 'Test' + msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600' + msg.set_content("body") + result = extract_metadata(msg) + assert result['from'] is None + + +class TestMissingDate: + def test_date_is_none(self): + msg = EmailMessage() + msg['From'] = 'test@example.com' + msg['To'] = 'craig@example.com' + msg['Subject'] = 'Test' + msg.set_content("body") + result = extract_metadata(msg) + assert result['date'] is None + + +class TestLongSubject: + def test_full_subject_returned(self): + long_subject = "Re: Fw: This is a very long subject line that spans many words and might be folded" + msg = make_plain_message(subject=long_subject) + result = extract_metadata(msg) + assert result['subject'] == long_subject diff --git a/claude-templates/.ai/scripts/tests/test_generate_filenames.py b/claude-templates/.ai/scripts/tests/test_generate_filenames.py new file mode 100644 index 0000000..07c8f84 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_generate_filenames.py @@ -0,0 +1,157 @@ +"""Tests for generate_basename(), generate_email_filename(), generate_attachment_filename().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +generate_basename = eml_script.generate_basename +generate_email_filename = eml_script.generate_email_filename +generate_attachment_filename = eml_script.generate_attachment_filename + + +# --- generate_basename --- + +class TestGenerateBasename: + def test_standard_from_and_date(self): + metadata = { + 'from': 'Jonathan Smith <jsmith@example.com>', + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + assert generate_basename(metadata) == "2026-02-05-1136-Jonathan" + + def test_from_with_display_name_first_token(self): + metadata = { + 'from': 'C Ciarm <cciarm@example.com>', + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + result = generate_basename(metadata) + assert result == "2026-02-05-1136-C" + + def test_from_without_display_name(self): + metadata = { + 'from': 'jsmith@example.com', + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + result = generate_basename(metadata) + assert result == "2026-02-05-1136-jsmith" + + def test_missing_date(self): + metadata = { + 'from': 'Jonathan Smith <jsmith@example.com>', + 'date': None, + } + result = generate_basename(metadata) + assert result == "unknown-Jonathan" + + def test_missing_from(self): + metadata = { + 'from': None, + 'date': 'Wed, 05 Feb 2026 11:36:00 -0600', + } + result = generate_basename(metadata) + assert result == "2026-02-05-1136-unknown" + + def test_both_missing(self): + metadata = {'from': None, 'date': None} + result = generate_basename(metadata) + assert result == "unknown-unknown" + + def test_unparseable_date(self): + metadata = { + 'from': 'Jonathan <j@example.com>', + 'date': 'not a real date', + } + result = generate_basename(metadata) + assert result == "unknown-Jonathan" + + def test_none_date_no_crash(self): + metadata = {'from': 'Test <t@e.com>', 'date': None} + # Should not raise + result = generate_basename(metadata) + assert "unknown" in result + + +# --- generate_email_filename --- + +class TestGenerateEmailFilename: + def test_standard_subject(self): + result = generate_email_filename( + "2026-02-05-1136-Jonathan", + "Re: Fw: 4319 Danneel Street" + ) + assert result == "2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street" + + def test_subject_with_special_chars(self): + result = generate_email_filename( + "2026-02-05-1136-Jonathan", + "Update: Meeting (draft) & notes!" + ) + # Colons, parens, ampersands, exclamation stripped + assert "EMAIL" in result + assert ":" not in result + assert "(" not in result + assert ")" not in result + assert "&" not in result + assert "!" not in result + + def test_none_subject(self): + result = generate_email_filename("2026-02-05-1136-Jonathan", None) + assert result == "2026-02-05-1136-Jonathan-EMAIL-no-subject" + + def test_empty_subject(self): + result = generate_email_filename("2026-02-05-1136-Jonathan", "") + assert result == "2026-02-05-1136-Jonathan-EMAIL-no-subject" + + def test_very_long_subject(self): + long_subject = "A" * 100 + " " + "B" * 100 + result = generate_email_filename("2026-02-05-1136-Jonathan", long_subject) + # The cleaned subject part should be truncated + # basename (27) + "-EMAIL-" (7) + subject + # Subject itself is limited to 80 chars by _clean_for_filename + subject_part = result.split("-EMAIL-")[1] + assert len(subject_part) <= 80 + + +# --- generate_attachment_filename --- + +class TestGenerateAttachmentFilename: + def test_standard_attachment(self): + result = generate_attachment_filename( + "2026-02-05-1136-Jonathan", + "Ltr Carrollton.pdf" + ) + assert result == "2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf" + + def test_filename_with_spaces_and_parens(self): + result = generate_attachment_filename( + "2026-02-05-1136-Jonathan", + "Document (final copy).pdf" + ) + assert " " not in result + assert "(" not in result + assert ")" not in result + assert result.endswith(".pdf") + + def test_preserves_extension(self): + result = generate_attachment_filename( + "2026-02-05-1136-Jonathan", + "photo.jpg" + ) + assert result.endswith(".jpg") + + def test_none_filename(self): + result = generate_attachment_filename("2026-02-05-1136-Jonathan", None) + assert result == "2026-02-05-1136-Jonathan-ATTACH-unnamed" + + def test_empty_filename(self): + result = generate_attachment_filename("2026-02-05-1136-Jonathan", "") + assert result == "2026-02-05-1136-Jonathan-ATTACH-unnamed" diff --git a/claude-templates/.ai/scripts/tests/test_gmail_fetch_attachments.py b/claude-templates/.ai/scripts/tests/test_gmail_fetch_attachments.py new file mode 100644 index 0000000..b4fba41 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_gmail_fetch_attachments.py @@ -0,0 +1,420 @@ +"""Tests for gmail-fetch-attachments.py. + +Covers: +- Pure helpers: safe_filename, collect_attachments, load_client_creds +- File I/O: load_mcp_env, load_refresh_token (tmp_path + monkeypatch on + module-level constants CLAUDE_CONFIG and TOKEN_DIR) +- HTTP wrappers: refresh_access_token, gmail_get (monkeypatch on + urllib.request.urlopen) +- Argparse: --help / missing-args via subprocess + +Strategy mirrors test_cmail_action.py: import the script via importlib +(filename has hyphens), mock at external boundaries, no integration +test for main() — the components are tested individually. +""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "gmail-fetch-attachments.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "gmail_fetch_attachments", str(SCRIPT_PATH) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def gfa(): + return _load_module() + + +def _mock_urlopen_response(payload): + """Build a MagicMock mimicking urllib.request.urlopen()'s context-manager response.""" + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(payload).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + return mock_resp + + +# --------------------------------------------------------------------------- +# safe_filename — pure +# --------------------------------------------------------------------------- + +class TestSafeFilename: + + def test_normal_clean_filename(self, gfa): + assert gfa.safe_filename("report.pdf") == "report.pdf" + + def test_boundary_forward_slash_replaced_with_underscore(self, gfa): + assert gfa.safe_filename("foo/bar.txt") == "foo_bar.txt" + + def test_boundary_backslash_replaced_with_underscore(self, gfa): + assert gfa.safe_filename("foo\\bar.txt") == "foo_bar.txt" + + def test_boundary_path_traversal_stripped(self, gfa): + # "../etc/passwd" -> after slash replace: ".._etc_passwd" + # While loop strips leading "..": "_etc_passwd" + assert gfa.safe_filename("../etc/passwd") == "_etc_passwd" + + def test_boundary_dotfile_preserved(self, gfa): + # The fix Craig requested: single-dot prefixes survive so dotfiles + # like .gitignore aren't silently renamed. + assert gfa.safe_filename(".gitignore") == ".gitignore" + assert gfa.safe_filename(".env.local") == ".env.local" + + def test_boundary_empty_string(self, gfa): + assert gfa.safe_filename("") == "" + + @pytest.mark.parametrize("input_name,expected", [ + ("..", ""), # single ".." stripped, leaves empty + ("...", "."), # one strip leaves a single dot + ("....", ""), # two strips leave empty + (".....", "."), # two strips leave one dot + ]) + def test_boundary_only_dots(self, gfa, input_name, expected): + assert gfa.safe_filename(input_name) == expected + + def test_boundary_double_dot_followed_by_name_stripped(self, gfa): + assert gfa.safe_filename("..foo") == "foo" + + def test_boundary_middle_dotdot_preserved(self, gfa): + # Only LEADING ".." gets stripped. Mid-string ".." stays. + # "foo..bar" has no leading dots, so it's preserved as-is. + assert gfa.safe_filename("foo..bar") == "foo..bar" + + +# --------------------------------------------------------------------------- +# collect_attachments — pure +# --------------------------------------------------------------------------- + +class TestCollectAttachments: + + def test_normal_single_attachment(self, gfa): + payload = { + "parts": [ + {"mimeType": "text/plain", "body": {"size": 100}}, + {"filename": "doc.pdf", "mimeType": "application/pdf", + "body": {"attachmentId": "abc123", "size": 5000}}, + ] + } + result = gfa.collect_attachments(payload) + assert result == [{ + "filename": "doc.pdf", + "attachmentId": "abc123", + "size": 5000, + "mimeType": "application/pdf", + }] + + def test_boundary_nested_multipart_recursion(self, gfa): + payload = { + "parts": [ + {"mimeType": "multipart/mixed", "parts": [ + {"mimeType": "multipart/alternative", "parts": [ + {"filename": "deep.pdf", "mimeType": "application/pdf", + "body": {"attachmentId": "deep1", "size": 100}}, + ]}, + ]}, + ] + } + result = gfa.collect_attachments(payload) + assert len(result) == 1 + assert result[0]["filename"] == "deep.pdf" + assert result[0]["attachmentId"] == "deep1" + + def test_boundary_no_attachments_returns_empty(self, gfa): + payload = { + "parts": [ + {"mimeType": "text/plain", "body": {"size": 100}}, + {"mimeType": "text/html", "body": {"size": 200}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_inline_image_no_filename_skipped(self, gfa): + # Inline images embedded via cid: typically have an attachmentId + # but no filename. The "user-visible attachments" heuristic skips + # them so they don't litter the output dir as image001.png. + payload = { + "parts": [ + {"mimeType": "image/png", + "body": {"attachmentId": "inline1", "size": 500}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_empty_filename_skipped(self, gfa): + # Empty-string filename also skipped (truthy check). + payload = { + "parts": [ + {"filename": "", "mimeType": "image/png", + "body": {"attachmentId": "empty1", "size": 500}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_filename_without_attachment_id_skipped(self, gfa): + # A part with a filename but no attachmentId isn't a separately + # downloadable attachment — it's inline content with a name. + payload = { + "parts": [ + {"filename": "fake.txt", "mimeType": "text/plain", + "body": {"size": 100}}, + ] + } + assert gfa.collect_attachments(payload) == [] + + def test_boundary_multiple_attachments_at_different_depths(self, gfa): + payload = { + "parts": [ + {"filename": "top.pdf", "mimeType": "application/pdf", + "body": {"attachmentId": "top1", "size": 100}}, + {"mimeType": "multipart/mixed", "parts": [ + {"filename": "nested.txt", "mimeType": "text/plain", + "body": {"attachmentId": "nested1", "size": 50}}, + ]}, + ] + } + result = gfa.collect_attachments(payload) + names = sorted(r["filename"] for r in result) + assert names == ["nested.txt", "top.pdf"] + + def test_boundary_default_mimetype_when_missing(self, gfa): + payload = { + "parts": [ + {"filename": "x.bin", + "body": {"attachmentId": "x1", "size": 10}}, + ] + } + result = gfa.collect_attachments(payload) + assert result[0]["mimeType"] == "application/octet-stream" + + def test_error_empty_payload(self, gfa): + assert gfa.collect_attachments({}) == [] + + def test_error_payload_with_null_parts(self, gfa): + # Defensive: parts = None falls through to empty list via `or []`. + payload = {"parts": None} + assert gfa.collect_attachments(payload) == [] + + +# --------------------------------------------------------------------------- +# load_client_creds — pure +# --------------------------------------------------------------------------- + +class TestLoadClientCreds: + + def test_normal_both_credentials_present(self, gfa): + env = {"GOOGLE_CLIENT_ID": "cid123", "GOOGLE_CLIENT_SECRET": "secret456"} + assert gfa.load_client_creds(env) == ("cid123", "secret456") + + def test_error_missing_client_id(self, gfa): + env = {"GOOGLE_CLIENT_SECRET": "secret456"} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + def test_error_missing_client_secret(self, gfa): + env = {"GOOGLE_CLIENT_ID": "cid123"} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + def test_error_empty_client_id(self, gfa): + env = {"GOOGLE_CLIENT_ID": "", "GOOGLE_CLIENT_SECRET": "secret456"} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + def test_error_empty_client_secret(self, gfa): + env = {"GOOGLE_CLIENT_ID": "cid123", "GOOGLE_CLIENT_SECRET": ""} + with pytest.raises(SystemExit): + gfa.load_client_creds(env) + + +# --------------------------------------------------------------------------- +# load_mcp_env — file I/O via tmp_path + monkeypatch CLAUDE_CONFIG +# --------------------------------------------------------------------------- + +class TestLoadMcpEnv: + + @staticmethod + def _write_config(tmp_path, monkeypatch, gfa, content): + config_path = tmp_path / ".claude.json" + config_path.write_text(json.dumps(content)) + monkeypatch.setattr(gfa, "CLAUDE_CONFIG", config_path) + return config_path + + def test_normal_personal_profile_with_env(self, monkeypatch, gfa, tmp_path): + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": { + "google-docs-personal": { + "env": {"GOOGLE_CLIENT_ID": "cid", "GOOGLE_CLIENT_SECRET": "sec"} + } + } + }) + env = gfa.load_mcp_env("personal") + assert env == {"GOOGLE_CLIENT_ID": "cid", "GOOGLE_CLIENT_SECRET": "sec"} + + def test_boundary_server_present_no_env_key(self, monkeypatch, gfa, tmp_path): + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": {"google-docs-work": {}} + }) + assert gfa.load_mcp_env("work") == {} + + def test_boundary_env_explicitly_null(self, monkeypatch, gfa, tmp_path): + # The `or {}` defends against null env. Returns empty dict, not None. + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": {"google-docs-personal": {"env": None}} + }) + assert gfa.load_mcp_env("personal") == {} + + def test_error_config_file_missing(self, monkeypatch, gfa, tmp_path): + monkeypatch.setattr(gfa, "CLAUDE_CONFIG", tmp_path / "nope.json") + with pytest.raises(SystemExit): + gfa.load_mcp_env("personal") + + def test_error_server_not_in_config(self, monkeypatch, gfa, tmp_path): + self._write_config(tmp_path, monkeypatch, gfa, { + "mcpServers": {"google-docs-personal": {"env": {}}} + }) + with pytest.raises(SystemExit): + gfa.load_mcp_env("work") + + +# --------------------------------------------------------------------------- +# load_refresh_token — file I/O via tmp_path + monkeypatch TOKEN_DIR +# --------------------------------------------------------------------------- + +class TestLoadRefreshToken: + + @staticmethod + def _setup_token(tmp_path, monkeypatch, gfa, profile=None, content=None): + token_dir = tmp_path / "google-docs-mcp" + token_dir.mkdir() + if profile: + (token_dir / profile).mkdir() + token_path = token_dir / profile / "token.json" + else: + token_path = token_dir / "token.json" + if content is not None: + token_path.write_text(json.dumps(content)) + monkeypatch.setattr(gfa, "TOKEN_DIR", token_dir) + return token_path + + def test_normal_no_profile_token_at_root(self, monkeypatch, gfa, tmp_path): + self._setup_token(tmp_path, monkeypatch, gfa, + content={"refresh_token": "rt-root"}) + assert gfa.load_refresh_token({}) == "rt-root" + + def test_boundary_with_profile_subdir(self, monkeypatch, gfa, tmp_path): + self._setup_token(tmp_path, monkeypatch, gfa, profile="personal", + content={"refresh_token": "rt-personal"}) + assert gfa.load_refresh_token( + {"GOOGLE_MCP_PROFILE": "personal"} + ) == "rt-personal" + + def test_boundary_explicit_empty_profile_falls_back_to_root( + self, monkeypatch, gfa, tmp_path): + # GOOGLE_MCP_PROFILE="" is treated the same as the key being missing — + # both fall back to TOKEN_DIR/token.json. Pinning both shapes so a + # future refactor that drops `or ""` doesn't silently break this. + self._setup_token(tmp_path, monkeypatch, gfa, + content={"refresh_token": "rt-root"}) + assert gfa.load_refresh_token({"GOOGLE_MCP_PROFILE": ""}) == "rt-root" + + def test_error_token_file_missing(self, monkeypatch, gfa, tmp_path): + token_dir = tmp_path / "google-docs-mcp" + token_dir.mkdir() + monkeypatch.setattr(gfa, "TOKEN_DIR", token_dir) + with pytest.raises(SystemExit): + gfa.load_refresh_token({}) + + def test_error_no_refresh_token_field_in_file(self, monkeypatch, gfa, tmp_path): + self._setup_token(tmp_path, monkeypatch, gfa, + content={"access_token": "at-only"}) + with pytest.raises(SystemExit): + gfa.load_refresh_token({}) + + +# --------------------------------------------------------------------------- +# refresh_access_token — mocked urllib +# --------------------------------------------------------------------------- + +class TestRefreshAccessToken: + + def test_normal_returns_access_token(self, monkeypatch, gfa): + mock_urlopen = MagicMock( + return_value=_mock_urlopen_response({"access_token": "at-new"}) + ) + monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen) + result = gfa.refresh_access_token("rt-val", "cid-val", "sec-val") + assert result == "at-new" + # Verify the request shape: URL, body grant_type and refresh_token. + req = mock_urlopen.call_args[0][0] + assert req.full_url == gfa.OAUTH_TOKEN_URL + body = req.data.decode() + assert "grant_type=refresh_token" in body + assert "refresh_token=rt-val" in body + assert "client_id=cid-val" in body + + def test_error_response_missing_access_token(self, monkeypatch, gfa): + mock_urlopen = MagicMock( + return_value=_mock_urlopen_response({"error": "invalid_grant"}) + ) + monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen) + with pytest.raises(SystemExit): + gfa.refresh_access_token("rt", "cid", "sec") + + +# --------------------------------------------------------------------------- +# gmail_get — mocked urllib +# --------------------------------------------------------------------------- + +class TestGmailGet: + + def test_normal_returns_parsed_json_with_bearer_header(self, monkeypatch, gfa): + mock_urlopen = MagicMock( + return_value=_mock_urlopen_response({"id": "msg123", "snippet": "hi"}) + ) + monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen) + result = gfa.gmail_get("/messages/msg123", "at-token") + assert result == {"id": "msg123", "snippet": "hi"} + req = mock_urlopen.call_args[0][0] + assert req.full_url == f"{gfa.GMAIL_API}/messages/msg123" + # urllib.request.Request lowercases header names except the first + # char via .capitalize() → "Authorization" stays as "Authorization". + assert req.headers["Authorization"] == "Bearer at-token" + + +# --------------------------------------------------------------------------- +# Argparse — black-box subprocess sanity check +# --------------------------------------------------------------------------- + +class TestArgparseShape: + + def test_normal_help_lists_all_required_args(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + for flag in ("--profile", "--message-id", "--output-dir"): + assert flag in result.stdout + + def test_error_no_args_exits_nonzero(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH)], + capture_output=True, text=True, + ) + assert result.returncode != 0 diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py new file mode 100644 index 0000000..597a7e9 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py @@ -0,0 +1,329 @@ +"""Tests for inbox-send.py — universal cross-project inbox messaging tool. + +The script: +- discovers .ai projects with an inbox/ subdirectory under known roots, +- writes a text message as a dated .org file in the target's inbox/, or +- copies a file into the target's inbox/ with a dated, source-tagged name. + +All discovery is roots-driven (env var INBOX_SEND_ROOTS overrides the +defaults) so tests can sandbox everything inside tmp_path. +""" + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "inbox-send.py" + + +@pytest.fixture +def project_root(tmp_path): + """Build a fake project under tmp_path/projects/<name>/ with .ai/ + top-level inbox/.""" + def _make(name: str, has_inbox: bool = True) -> Path: + proj = tmp_path / "projects" / name + proj.mkdir(parents=True, exist_ok=True) + (proj / ".ai").mkdir(exist_ok=True) + if has_inbox: + (proj / "inbox").mkdir(exist_ok=True) + return proj + return _make + + +@pytest.fixture +def run_script(tmp_path): + """Invoke inbox-send with sandboxed roots via INBOX_SEND_ROOTS env var.""" + def _run(args, cwd=None, roots=None, expect_failure=False): + env = {} + # Preserve PATH and a few essentials for python3 to launch. + import os as _os + env["PATH"] = _os.environ.get("PATH", "") + env["HOME"] = _os.environ.get("HOME", "/tmp") + if roots: + env["INBOX_SEND_ROOTS"] = ":".join(str(r) for r in roots) + cmd = ["python3", str(SCRIPT)] + args + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=cwd or tmp_path, + env=env, + check=not expect_failure, + ) + return result + return _run + + +# ---------------------------------------------------------------------- +# Discovery (--list) +# ---------------------------------------------------------------------- + +class TestInboxSendDiscovery: + """Discovering available .ai projects under the configured roots.""" + + def test_inbox_send_list_detects_projects_with_ai_inbox(self, project_root, run_script, tmp_path): + """Normal: --list shows projects that have .ai/inbox/.""" + project_root("foo") + project_root("bar") + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "foo" in result.stdout + assert "bar" in result.stdout + + def test_inbox_send_list_skips_projects_without_inbox(self, project_root, run_script, tmp_path): + """Boundary: project with .ai/ but no inbox/ is not surfaced.""" + project_root("withinbox", has_inbox=True) + project_root("noinbox", has_inbox=False) + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert "withinbox" in result.stdout + assert "noinbox" not in result.stdout + + def test_inbox_send_list_skips_current_project(self, project_root, run_script, tmp_path): + """Normal: --list excludes the project the user is currently in.""" + cwd_project = project_root("current") + project_root("other") + result = run_script(["--list"], cwd=cwd_project, roots=[tmp_path / "projects"]) + assert "other" in result.stdout + assert "current" not in result.stdout + + def test_inbox_send_list_empty_when_no_projects(self, run_script, tmp_path): + """Boundary: no projects under roots → friendly informational message.""" + (tmp_path / "projects").mkdir() + result = run_script(["--list"], roots=[tmp_path / "projects"]) + assert result.returncode == 0 + assert "No projects" in result.stdout + + def test_inbox_send_list_handles_missing_root(self, run_script, tmp_path): + """Boundary: configured root doesn't exist → skip silently.""" + result = run_script(["--list"], roots=[tmp_path / "does-not-exist"]) + assert result.returncode == 0 + + +# ---------------------------------------------------------------------- +# Slug derivation from text and from filenames +# ---------------------------------------------------------------------- + +def _slug_from(inbox_files, source_name): + """Helper: extract the slug from a deposited file's basename.""" + assert len(inbox_files) == 1 + name = inbox_files[0].stem + marker = f"from-{source_name}-" + return name.split(marker, 1)[1] + + +class TestInboxSendNaming: + """Slug derivation from --text (and override via --name).""" + + def test_inbox_send_text_slug_hyphenated_lowercase(self, project_root, run_script, tmp_path): + """Normal: 'ATM cash reminder' → slug 'atm-cash-reminder'.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "ATM cash reminder"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert _slug_from(files, "source") == "atm-cash-reminder" + + def test_inbox_send_text_slug_truncated_at_word_boundary(self, project_root, run_script, tmp_path): + """Normal: long text truncated under 40 chars at the nearest word boundary.""" + project_root("target") + cwd = project_root("source") + long_text = ( + "Please review the SOFWeek prep doc and confirm the AirBnB kitchen details" + ) + run_script( + ["target", "--text", long_text], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + slug = _slug_from(files, "source") + assert slug.startswith("please-review-the-sofweek") + assert len(slug) <= 40 + # Truncation should land on a word boundary (last char is a letter/digit, not mid-word). + assert "-" not in slug[-1] + + def test_inbox_send_text_slug_strips_punctuation(self, project_root, run_script, tmp_path): + """Normal: punctuation stripped, lowercased.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "Hey! What's the plan? See you @ 5PM."], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + slug = _slug_from(files, "source") + for ch in "!?'@.": + assert ch not in slug + assert slug == slug.lower() + + def test_inbox_send_name_override_overrides_slug(self, project_root, run_script, tmp_path): + """Normal: --name wins over derived slug.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "ok", "--name", "pre-call-ack"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert _slug_from(files, "source") == "pre-call-ack" + + +# ---------------------------------------------------------------------- +# --text mode end-to-end +# ---------------------------------------------------------------------- + +class TestInboxSendText: + """--text mode writes a .org file with the message body.""" + + def test_inbox_send_text_writes_org_file_with_message(self, project_root, run_script, tmp_path): + """Normal: produces a .org file whose body contains the message.""" + project_root("target") + cwd = project_root("source") + run_script( + ["target", "--text", "Remember the ATM run"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert len(files) == 1 + assert files[0].suffix == ".org" + body = files[0].read_text() + assert "Remember the ATM run" in body + + def test_inbox_send_text_filename_includes_source_project_name(self, project_root, run_script, tmp_path): + """Normal: filename includes 'from-<source>-' so the target knows where it came from.""" + project_root("target") + cwd = project_root("emacs") + run_script( + ["target", "--text", "hello"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "from-emacs-" in files[0].name + + +# ---------------------------------------------------------------------- +# --file mode end-to-end +# ---------------------------------------------------------------------- + +class TestInboxSendFile: + """--file mode copies the source file into the target inbox.""" + + def test_inbox_send_file_copies_text_file(self, project_root, run_script, tmp_path): + """Normal: copies a text file to the target inbox, preserving content.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "doc.org" + src.write_text("file content") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert len(files) == 1 + assert files[0].read_text() == "file content" + + def test_inbox_send_file_preserves_extension(self, project_root, run_script, tmp_path): + """Normal: extension carried from source file.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "image.png" + src.write_bytes(b"\x89PNG\r\n...") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert files[0].suffix == ".png" + + def test_inbox_send_file_slug_from_source_basename(self, project_root, run_script, tmp_path): + """Normal: filename slug derived from the source file's basename when --name omitted.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "branching-strategy-notes.md" + src.write_text("notes") + run_script( + ["target", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "branching-strategy-notes" in files[0].name + + def test_inbox_send_file_name_override(self, project_root, run_script, tmp_path): + """Normal: --name overrides the basename-derived slug; extension preserved.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "random.pdf" + src.write_bytes(b"%PDF-1.4...") + run_script( + ["target", "--file", str(src), "--name", "branching-strategy"], + cwd=cwd, roots=[tmp_path / "projects"], + ) + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert "branching-strategy" in files[0].name + assert files[0].suffix == ".pdf" + + +# ---------------------------------------------------------------------- +# Errors and refusal cases +# ---------------------------------------------------------------------- + +class TestInboxSendErrors: + """Refusal cases — surface clearly, exit non-zero, leave filesystem untouched.""" + + def test_inbox_send_refuses_unknown_target(self, project_root, run_script, tmp_path): + """Error: target project not found in discovery → refuse.""" + cwd = project_root("source") + result = run_script( + ["nonexistent", "--text", "hi"], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_no_text_and_no_file(self, project_root, run_script, tmp_path): + """Error: must provide one of --text / --file.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target"], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_both_text_and_file(self, project_root, run_script, tmp_path): + """Error: --text and --file are mutually exclusive.""" + project_root("target") + cwd = project_root("source") + src = tmp_path / "doc.org" + src.write_text("x") + result = run_script( + ["target", "--text", "hi", "--file", str(src)], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_missing_source_file(self, project_root, run_script, tmp_path): + """Error: --file path doesn't exist → refuse.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target", "--file", str(tmp_path / "definitely-missing.org")], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + + def test_inbox_send_refuses_empty_text(self, project_root, run_script, tmp_path): + """Error: empty --text refused; nothing written to target inbox.""" + project_root("target") + cwd = project_root("source") + result = run_script( + ["target", "--text", " "], + cwd=cwd, roots=[tmp_path / "projects"], + expect_failure=True, + ) + assert result.returncode != 0 + files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) + assert files == [] diff --git a/claude-templates/.ai/scripts/tests/test_integration_stdout.py b/claude-templates/.ai/scripts/tests/test_integration_stdout.py new file mode 100644 index 0000000..d87478e --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_integration_stdout.py @@ -0,0 +1,68 @@ +"""Integration tests for backwards-compatible stdout mode (no --output-dir).""" + +import os +import shutil +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +print_email = eml_script.print_email + +FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures') + + +class TestPlainTextStdout: + def test_metadata_and_body_printed(self, tmp_path, capsys): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + working_eml = tmp_path / "message.eml" + shutil.copy2(eml_src, working_eml) + + print_email(str(working_eml)) + captured = capsys.readouterr() + + assert "From: Jonathan Smith <jsmith@example.com>" in captured.out + assert "To: Craig Jennings <craig@example.com>" in captured.out + assert "Subject: Re: Fw: 4319 Danneel Street" in captured.out + assert "Date:" in captured.out + assert "Sent:" in captured.out + assert "Received:" in captured.out + assert "4319 Danneel Street" in captured.out + + +class TestHtmlFallbackStdout: + def test_html_converted_on_stdout(self, tmp_path, capsys): + eml_src = os.path.join(FIXTURES, 'html-only.eml') + working_eml = tmp_path / "message.eml" + shutil.copy2(eml_src, working_eml) + + print_email(str(working_eml)) + captured = capsys.readouterr() + + # Should see converted text, not raw HTML + assert "HTML" in captured.out + assert "<p>" not in captured.out + + +class TestAttachmentsStdout: + def test_attachment_extracted_alongside_eml(self, tmp_path, capsys): + eml_src = os.path.join(FIXTURES, 'with-attachment.eml') + working_eml = tmp_path / "message.eml" + shutil.copy2(eml_src, working_eml) + + print_email(str(working_eml)) + captured = capsys.readouterr() + + assert "Extracted attachment:" in captured.out + assert "Ltr Carrollton.pdf" in captured.out + + # File should exist alongside the EML + extracted = tmp_path / "Ltr Carrollton.pdf" + assert extracted.exists() diff --git a/claude-templates/.ai/scripts/tests/test_maildir_flag_manager.py b/claude-templates/.ai/scripts/tests/test_maildir_flag_manager.py new file mode 100644 index 0000000..268af5b --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_maildir_flag_manager.py @@ -0,0 +1,310 @@ +"""Tests for maildir-flag-manager.py. + +Covers: +- Pure parsers: parse_maildir_flags, build_flagged_filename +- File-I/O ops: rename_with_flag, process_maildir, process_specific_files + (tmp_path with real maildir directory structures) +- Subprocess wrapper: reindex_mu (monkeypatch on shutil.which + subprocess.run) +- Argparse: --help / missing-subcommand via subprocess + +The cmd_mark_read / cmd_star orchestrators are intentionally skipped — +they call the helpers and print summaries; the helpers are tested +directly so testing the orchestrators would mostly assert call counts. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "maildir-flag-manager.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "maildir_flag_manager", str(SCRIPT_PATH) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def mfm(): + return _load_module() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_maildir(tmp_path: Path, files=None) -> Path: + """Construct a maildir at tmp_path/inbox with new/ and cur/ subdirs. + + files is a list of (subdir, filename) tuples. Each becomes an empty + file at tmp_path/inbox/<subdir>/<filename>. + """ + inbox = tmp_path / "inbox" + (inbox / "new").mkdir(parents=True) + (inbox / "cur").mkdir() + for subdir, fname in (files or []): + (inbox / subdir / fname).write_text("body") + return inbox + + +# --------------------------------------------------------------------------- +# parse_maildir_flags — pure +# --------------------------------------------------------------------------- + +class TestParseMaildirFlags: + + def test_normal_typical_filename(self, mfm): + assert mfm.parse_maildir_flags("12345.host:2,FS") == ("12345.host", "FS") + + def test_boundary_no_flag_suffix(self, mfm): + # No ":2," in filename — return whole name as base, empty flags. + assert mfm.parse_maildir_flags("12345.host") == ("12345.host", "") + + def test_boundary_empty_flags_section(self, mfm): + # ":2," with nothing after — base is parsed, flags are empty. + assert mfm.parse_maildir_flags("12345.host:2,") == ("12345.host", "") + + def test_boundary_multiple_colons_in_base(self, mfm): + # rsplit on the LAST ":2," — base may contain colons or even ":2,"-like + # substrings. Real maildir names sometimes have these from migrations. + assert mfm.parse_maildir_flags("weird:thing:2,FS") == ("weird:thing", "FS") + + def test_boundary_empty_string(self, mfm): + assert mfm.parse_maildir_flags("") == ("", "") + + +# --------------------------------------------------------------------------- +# build_flagged_filename — pure +# --------------------------------------------------------------------------- + +class TestBuildFlaggedFilename: + + def test_normal_base_plus_flags(self, mfm): + assert mfm.build_flagged_filename("12345.host", "FS") == "12345.host:2,FS" + + def test_boundary_replaces_existing_flags(self, mfm): + # Existing flags get parsed away — the new_flags arg is the source of truth. + assert mfm.build_flagged_filename("12345.host:2,F", "FS") == "12345.host:2,FS" + + def test_boundary_flags_sorted_alphabetically(self, mfm): + # Maildir spec requires alphabetical sort. SFR -> FRS. + assert mfm.build_flagged_filename("12345.host", "SFR") == "12345.host:2,FRS" + + def test_boundary_duplicate_flags_dedup(self, mfm): + # set() dedups before sort. FFS -> FS. + assert mfm.build_flagged_filename("12345.host", "FFS") == "12345.host:2,FS" + + def test_boundary_empty_flags(self, mfm): + assert mfm.build_flagged_filename("12345.host", "") == "12345.host:2," + + +# --------------------------------------------------------------------------- +# rename_with_flag — file I/O via tmp_path +# --------------------------------------------------------------------------- + +class TestRenameWithFlag: + + def test_normal_add_F_to_cur_file_renamed_in_place(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,")]) + original = inbox / "cur" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "F") is True + assert not original.exists() + assert (inbox / "cur" / "12345.host:2,F").exists() + + def test_boundary_add_S_to_new_file_moves_to_cur(self, mfm, tmp_path): + # Maildir spec: messages with Seen flag belong in cur/, not new/. + inbox = _make_maildir(tmp_path, [("new", "12345.host:2,")]) + original = inbox / "new" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "S") is True + assert not original.exists() + # Should land in cur/, not new/. + assert (inbox / "cur" / "12345.host:2,S").exists() + assert not (inbox / "new" / "12345.host:2,S").exists() + + def test_boundary_add_F_to_new_file_stays_in_new(self, mfm, tmp_path): + # F (Flagged) doesn't trigger the new/ -> cur/ migration; only S does. + inbox = _make_maildir(tmp_path, [("new", "12345.host:2,")]) + original = inbox / "new" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "F") is True + assert (inbox / "new" / "12345.host:2,F").exists() + assert not (inbox / "cur" / "12345.host:2,F").exists() + + def test_boundary_flag_already_present_returns_false(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,FS")]) + original = inbox / "cur" / "12345.host:2,FS" + assert mfm.rename_with_flag(str(original), "F") is False + # Original file unchanged. + assert original.exists() + + def test_boundary_dry_run_does_not_modify_filesystem(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,")]) + original = inbox / "cur" / "12345.host:2," + assert mfm.rename_with_flag(str(original), "F", dry_run=True) is True + # Original still exists, no new file. + assert original.exists() + assert not (inbox / "cur" / "12345.host:2,F").exists() + + def test_error_file_path_does_not_exist(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path) + with pytest.raises(FileNotFoundError): + mfm.rename_with_flag(str(inbox / "cur" / "ghost:2,"), "F") + + +# --------------------------------------------------------------------------- +# process_maildir — tmp_path +# --------------------------------------------------------------------------- + +class TestProcessMaildir: + + def test_normal_mixed_flagged_and_unflagged(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [ + ("new", "msg1:2,"), + ("new", "msg2:2,"), + ("cur", "msg3:2,S"), # already has S, will skip + ("cur", "msg4:2,"), + ]) + changed, skipped, errors = mfm.process_maildir(str(inbox), "S") + # 3 didn't have S yet, 1 already did. + assert (changed, skipped, errors) == (3, 1, 0) + # The two from new/ have moved to cur/ (S triggers the migration). + assert (inbox / "cur" / "msg1:2,S").exists() + assert (inbox / "cur" / "msg2:2,S").exists() + assert (inbox / "cur" / "msg4:2,S").exists() + + def test_boundary_empty_maildir(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path) + assert mfm.process_maildir(str(inbox), "S") == (0, 0, 0) + + def test_boundary_maildir_does_not_exist(self, mfm, tmp_path, capsys): + # Returns (0, 0, 0) and logs a friendly message to stderr. + result = mfm.process_maildir(str(tmp_path / "nope"), "S") + assert result == (0, 0, 0) + err = capsys.readouterr().err + assert "Skipping" in err + + def test_boundary_non_file_entries_skipped(self, mfm, tmp_path): + # A stray subdirectory in cur/ shouldn't crash the scan. + inbox = _make_maildir(tmp_path, [("cur", "msg1:2,")]) + (inbox / "cur" / "stray-dir").mkdir() + changed, skipped, errors = mfm.process_maildir(str(inbox), "S") + assert (changed, skipped, errors) == (1, 0, 0) + + def test_boundary_only_new_subdir_present(self, mfm, tmp_path): + # If cur/ doesn't exist, the loop just skips it instead of erroring. + inbox = tmp_path / "inbox" + (inbox / "new").mkdir(parents=True) + (inbox / "new" / "msg1:2,").write_text("body") + changed, skipped, errors = mfm.process_maildir(str(inbox), "F") + assert (changed, skipped, errors) == (1, 0, 0) + + +# --------------------------------------------------------------------------- +# process_specific_files — tmp_path +# --------------------------------------------------------------------------- + +class TestProcessSpecificFiles: + + def test_normal_paths_in_cur_and_new(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [ + ("cur", "msg1:2,"), + ("new", "msg2:2,"), + ]) + paths = [ + str(inbox / "cur" / "msg1:2,"), + str(inbox / "new" / "msg2:2,"), + ] + changed, skipped, errors = mfm.process_specific_files(paths, "F") + assert (changed, skipped, errors) == (2, 0, 0) + + def test_error_file_not_found(self, mfm, tmp_path, capsys): + inbox = _make_maildir(tmp_path) + ghost = str(inbox / "cur" / "ghost:2,") + changed, skipped, errors = mfm.process_specific_files([ghost], "F") + assert errors == 1 + assert "File not found" in capsys.readouterr().err + + def test_error_file_outside_cur_or_new(self, mfm, tmp_path, capsys): + # Path validation: only files whose parent dir is named "cur" or "new" + # are accepted. Defends against pointing at the wrong file. + bogus = tmp_path / "elsewhere" / "msg1:2," + bogus.parent.mkdir() + bogus.write_text("body") + changed, skipped, errors = mfm.process_specific_files([str(bogus)], "F") + assert errors == 1 + assert "Not in a maildir" in capsys.readouterr().err + # File untouched. + assert bogus.exists() + + def test_error_already_set_counted_as_skipped(self, mfm, tmp_path): + inbox = _make_maildir(tmp_path, [("cur", "msg1:2,F")]) + path = str(inbox / "cur" / "msg1:2,F") + changed, skipped, errors = mfm.process_specific_files([path], "F") + assert (changed, skipped, errors) == (0, 1, 0) + + +# --------------------------------------------------------------------------- +# reindex_mu — mocked subprocess +# --------------------------------------------------------------------------- + +class TestReindexMu: + + def test_normal_mu_present_returns_true(self, mfm, monkeypatch): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu") + result_obj = MagicMock(returncode=0, stderr="") + monkeypatch.setattr(mfm.subprocess, "run", lambda *a, **kw: result_obj) + assert mfm.reindex_mu() is True + + def test_error_mu_not_in_path_returns_false(self, mfm, monkeypatch, capsys): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: None) + assert mfm.reindex_mu() is False + assert "mu not found" in capsys.readouterr().err + + def test_error_mu_index_returns_nonzero(self, mfm, monkeypatch, capsys): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu") + result_obj = MagicMock(returncode=1, stderr="db locked") + monkeypatch.setattr(mfm.subprocess, "run", lambda *a, **kw: result_obj) + assert mfm.reindex_mu() is False + assert "mu index failed" in capsys.readouterr().err + + def test_error_mu_index_times_out(self, mfm, monkeypatch, capsys): + monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu") + + def raise_timeout(*_a, **_kw): + raise subprocess.TimeoutExpired(cmd="mu index", timeout=120) + + monkeypatch.setattr(mfm.subprocess, "run", raise_timeout) + assert mfm.reindex_mu() is False + assert "timed out" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Argparse — black-box subprocess sanity check +# --------------------------------------------------------------------------- + +class TestArgparseShape: + + def test_normal_help_lists_subcommands(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "mark-read" in result.stdout + assert "star" in result.stdout + + def test_error_no_subcommand_exits_nonzero(self): + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH)], + capture_output=True, text=True, + ) + assert result.returncode != 0 diff --git a/claude-templates/.ai/scripts/tests/test_parse_received_headers.py b/claude-templates/.ai/scripts/tests/test_parse_received_headers.py new file mode 100644 index 0000000..e12e1fb --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_parse_received_headers.py @@ -0,0 +1,105 @@ +"""Tests for parse_received_headers().""" + +import email +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, add_received_headers +from email.message import EmailMessage + +# Import the function under test +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +parse_received_headers = eml_script.parse_received_headers + + +class TestSingleHeader: + def test_header_with_from_and_by(self): + msg = EmailMessage() + msg['Received'] = ( + 'from mail-sender.example.com by mx.receiver.example.com ' + 'with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600' + ) + result = parse_received_headers(msg) + assert result['sent_server'] == 'mail-sender.example.com' + assert result['received_server'] == 'mx.receiver.example.com' + assert result['sent_time'] == 'Thu, 05 Feb 2026 11:36:05 -0600' + assert result['received_time'] == 'Thu, 05 Feb 2026 11:36:05 -0600' + + +class TestMultipleHeaders: + def test_uses_first_with_both_from_and_by(self): + msg = EmailMessage() + # Most recent first (by only) + msg['Received'] = 'by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600' + # Next: has both from and by — this should be selected + msg['Received'] = ( + 'from mail-sender.example.com by mx.receiver.example.com ' + 'with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600' + ) + # Oldest + msg['Received'] = ( + 'from originator.example.com by relay.example.com ' + 'with SMTP; Thu, 05 Feb 2026 11:35:58 -0600' + ) + result = parse_received_headers(msg) + assert result['sent_server'] == 'mail-sender.example.com' + assert result['received_server'] == 'mx.receiver.example.com' + + +class TestNoReceivedHeaders: + def test_all_values_none(self): + msg = EmailMessage() + result = parse_received_headers(msg) + assert result['sent_time'] is None + assert result['sent_server'] is None + assert result['received_time'] is None + assert result['received_server'] is None + + +class TestByButNoFrom: + def test_falls_back_to_first_header(self): + msg = EmailMessage() + msg['Received'] = 'by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600' + result = parse_received_headers(msg) + assert result['received_server'] == 'internal.example.com' + assert result['received_time'] == 'Thu, 05 Feb 2026 11:36:10 -0600' + # No from in any header, so sent_server stays None + assert result['sent_server'] is None + + +class TestMultilineFoldedHeader: + def test_normalizes_whitespace(self): + # Use email.message_from_string to parse raw folded headers + # (EmailMessage policy rejects embedded CRLF in set values) + raw = ( + "From: test@example.com\r\n" + "Received: from mail-sender.example.com\r\n" + " by mx.receiver.example.com\r\n" + " with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600\r\n" + "\r\n" + "body\r\n" + ) + msg = email.message_from_string(raw) + result = parse_received_headers(msg) + assert result['sent_server'] == 'mail-sender.example.com' + assert result['received_server'] == 'mx.receiver.example.com' + + +class TestMalformedTimestamp: + def test_no_semicolon(self): + msg = EmailMessage() + msg['Received'] = 'from sender.example.com by receiver.example.com with SMTP' + result = parse_received_headers(msg) + assert result['sent_server'] == 'sender.example.com' + assert result['received_server'] == 'receiver.example.com' + assert result['sent_time'] is None + assert result['received_time'] is None diff --git a/claude-templates/.ai/scripts/tests/test_process_eml.py b/claude-templates/.ai/scripts/tests/test_process_eml.py new file mode 100644 index 0000000..612cbb1 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_process_eml.py @@ -0,0 +1,162 @@ +"""Integration tests for process_eml() — full pipeline with --output-dir.""" + +import os +import shutil +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +process_eml = eml_script.process_eml + +import pytest + + +FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures') + + +class TestPlainTextPipeline: + def test_creates_eml_and_txt(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + # Copy fixture to tmp_path so temp dir can be created as sibling + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + # Should have exactly 2 files: .eml and .txt + assert len(result['files']) == 2 + eml_file = result['files'][0] + txt_file = result['files'][1] + + assert eml_file['type'] == 'eml' + assert txt_file['type'] == 'txt' + assert eml_file['name'].endswith('.eml') + assert txt_file['name'].endswith('.txt') + + # Files exist in output dir + assert os.path.isfile(eml_file['path']) + assert os.path.isfile(txt_file['path']) + + # Filenames contain expected components + assert 'Jonathan' in eml_file['name'] + assert 'EMAIL' in eml_file['name'] + assert '2026-02-05' in eml_file['name'] + + # Temp dir cleaned up (no extract-* dirs in inbox) + inbox_contents = os.listdir(str(tmp_path / "inbox")) + assert not any(d.startswith('extract-') for d in inbox_contents) + + +class TestHtmlFallbackPipeline: + def test_txt_contains_converted_html(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'html-only.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + txt_file = result['files'][1] + with open(txt_file['path'], 'r') as f: + content = f.read() + + # Should be converted, not raw HTML + assert '<p>' not in content + assert '<strong>' not in content + assert 'HTML' in content + + +class TestAttachmentPipeline: + def test_eml_txt_and_attachment_created(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'with-attachment.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + assert len(result['files']) == 3 + types = [f['type'] for f in result['files']] + assert types == ['eml', 'txt', 'attach'] + + # Attachment is auto-renamed + attach_file = result['files'][2] + assert 'ATTACH' in attach_file['name'] + assert attach_file['name'].endswith('.pdf') + assert os.path.isfile(attach_file['path']) + + +class TestDuplicateAttachmentNames: + """Outlook inlines the same signature image multiple times under one + filename. Each part must be saved to its own file, not silently + overwritten in temp_dir (which leaves the move step pointing at a + missing file).""" + + def test_each_duplicate_attachment_kept_with_counter_suffix(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'duplicate-attachment-names.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + result = process_eml(str(working_eml), str(output_dir)) + + # eml + txt + 3 attachments + assert len(result['files']) == 5 + attach_files = [f for f in result['files'] if f['type'] == 'attach'] + assert len(attach_files) == 3 + + # Each file must have a unique name and exist on disk with its own + # bytes — overwriting earlier ones would leave fewer than 3 files + # and the move step would fail. + names = [f['name'] for f in attach_files] + assert len(set(names)) == 3 + for f in attach_files: + assert os.path.isfile(f['path']) + + # Bytes are preserved per part (fixture has -1, -2, -3 payloads) + contents = sorted(open(f['path'], 'rb').read() for f in attach_files) + assert contents == [b'image-content-1', b'image-content-2', b'image-content-3'] + + +class TestCollisionDetection: + def test_raises_on_existing_file(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "output" + # Run once to create files + result = process_eml(str(working_eml), str(output_dir)) + + # Run again — should raise FileExistsError + with pytest.raises(FileExistsError, match="Collision"): + process_eml(str(working_eml), str(output_dir)) + + +class TestMissingOutputDir: + def test_creates_directory(self, tmp_path): + eml_src = os.path.join(FIXTURES, 'plain-text.eml') + working_eml = tmp_path / "inbox" / "message.eml" + working_eml.parent.mkdir() + shutil.copy2(eml_src, working_eml) + + output_dir = tmp_path / "new" / "nested" / "output" + assert not output_dir.exists() + + result = process_eml(str(working_eml), str(output_dir)) + assert output_dir.exists() + assert len(result['files']) == 2 diff --git a/claude-templates/.ai/scripts/tests/test_save_attachments.py b/claude-templates/.ai/scripts/tests/test_save_attachments.py new file mode 100644 index 0000000..32f02a6 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_save_attachments.py @@ -0,0 +1,97 @@ +"""Tests for save_attachments().""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from conftest import make_plain_message, make_message_with_attachment +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication + +import importlib.util +spec = importlib.util.spec_from_file_location( + "eml_script", + os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py') +) +eml_script = importlib.util.module_from_spec(spec) +spec.loader.exec_module(eml_script) + +save_attachments = eml_script.save_attachments + + +class TestSingleAttachment: + def test_file_written_and_returned(self, tmp_path): + msg = make_message_with_attachment( + attachment_filename="report.pdf", + attachment_content=b"pdf bytes here" + ) + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + + assert len(result) == 1 + assert result[0]['original_name'] == "report.pdf" + assert "ATTACH" in result[0]['renamed_name'] + assert result[0]['renamed_name'].endswith(".pdf") + + # File actually exists and has correct content + written_path = result[0]['path'] + assert os.path.isfile(written_path) + with open(written_path, 'rb') as f: + assert f.read() == b"pdf bytes here" + + +class TestMultipleAttachments: + def test_all_written_and_returned(self, tmp_path): + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600' + msg.attach(MIMEText("body", 'plain')) + + for name, content in [("doc1.pdf", b"pdf1"), ("image.png", b"png1")]: + att = MIMEApplication(content, Name=name) + att['Content-Disposition'] = f'attachment; filename="{name}"' + msg.attach(att) + + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + + assert len(result) == 2 + for r in result: + assert os.path.isfile(r['path']) + + +class TestNoAttachments: + def test_empty_list(self, tmp_path): + msg = make_plain_message() + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + assert result == [] + + +class TestFilenameWithSpaces: + def test_cleaned_filename(self, tmp_path): + msg = make_message_with_attachment( + attachment_filename="My Document (1).pdf", + attachment_content=b"data" + ) + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + + assert len(result) == 1 + assert " " not in result[0]['renamed_name'] + assert os.path.isfile(result[0]['path']) + + +class TestNoContentDisposition: + def test_skipped(self, tmp_path): + msg = MIMEMultipart() + msg['From'] = 'test@example.com' + msg.attach(MIMEText("body", 'plain')) + + # Add a part without Content-Disposition + part = MIMEApplication(b"data", Name="file.bin") + # Explicitly remove Content-Disposition if present + if 'Content-Disposition' in part: + del part['Content-Disposition'] + msg.attach(part) + + result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan") + assert result == [] diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el new file mode 100644 index 0000000..569e7c7 --- /dev/null +++ b/claude-templates/.ai/scripts/todo-cleanup.el @@ -0,0 +1,514 @@ +;;; todo-cleanup.el --- Auto-fix and audit for todo.org hygiene -*- lexical-binding: t; -*- +;; +;; Usage: +;; emacs --batch -q -l todo-cleanup.el todo.org # apply hygiene fixes in place +;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only +;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees +;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive +;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's +;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check) +;; +;; Three independent modes: +;; +;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent, +;; safe to run every session. +;; +;; 1. Auto-deletes "bogus state-log" lines of the form +;; - State "X" from "X" [date] +;; where the state didn't actually change. Org sometimes logs these when +;; `org-log-into-drawer' is unset and a state-change toggle lands on the +;; same state. They carry no information and they break org's planning-line +;; parser by sitting between the heading and DEADLINE/SCHEDULED. +;; +;; 2. Detects "orphan planning lines" — entries whose body contains +;; `^DEADLINE:' or `^SCHEDULED:' that org-entry-get can't read because the +;; line isn't in canonical position. Reports these for manual fix; doesn't +;; auto-rewrite (preserving real state-log history is judgement work). +;; +;; * --archive-done (opt-in). Moves every level-2 subtree whose TODO state is +;; DONE or CANCELLED out of the "Open Work" section and into the "Resolved" +;; section of the same file, subtree intact. The sections are matched by a +;; unique level-1 heading containing "Open Work" (case-insensitive) and one +;; containing "Resolved"; if either is missing or ambiguous, the file is +;; skipped with a message. Only direct level-2 children move — a DONE entry +;; nested under an open parent stays put. Archiving is consequential, so it's +;; never run by default; it does *not* also run the hygiene passes. +;; +;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie +;; ([#A]-[#D]) and, for each of its direct child headings whose own priority +;; is lower (later in the alphabet — D is lower than A), bumps the child's +;; cookie to match the parent's. Down-only: parents are never adjusted to +;; match a child. Children with no priority cookie at all are left alone, as +;; are parents with no priority cookie. A child can opt out of being bumped +;; by carrying the `:no-sync:' tag — useful for `Follow-up:'/`Spike:' children +;; that are deliberately deprioritized. The opt-out inherits down the tree: +;; if any ancestor heading carries `:no-sync:', every descendant under it is +;; skipped, so tagging a top-level PROJECT once is enough to keep its whole +;; subtree from cascading. Because the walk visits parents +;; before their descendants in document order, a multi-level chain +;; ([#A] → [#B] → [#D]) collapses to the top priority in a single pass. +;; --check-child-priority is the report-only alias for --sync-child-priority +;; --check. + +(require 'org) +(require 'cl-lib) + +(setq org-todo-keywords + '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED"))) + +(defconst tc-done-states '("DONE" "CANCELLED") + "TODO keywords that mark an entry as completed for `--archive-done'.") + +(defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]" + "Regexp matching an org priority cookie. Match group 1 is the letter.") + +(defconst tc-no-sync-tag "no-sync" + "Org tag that opts a heading and all its descendants out of +`--sync-child-priority'. Inherits down: a tag on an ancestor counts for +every heading below it.") + +(defvar tc-fixes 0) +(defvar tc-archived 0) +(defvar tc-bumped 0) +(defvar tc-issues nil) +(defvar tc-check-only nil) +(defvar tc-archive-done nil) +(defvar tc-sync-child-priority nil) +(defvar tc-current-file nil) + +;;; --------------------------------------------------------------------------- +;;; Hygiene mode + +(defun tc-fix-bogus-state-log-in-entry () + "Delete bogus state-log lines within the entry at point. +A bogus log line matches `- State \"X\" from \"X\" [date]' where the two +states are identical." + (save-excursion + (let ((end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point)))) + (while (re-search-forward + "^[[:space:]]*- State \"\\([^\"]+\\)\"[[:space:]]+from \"\\1\"[[:space:]]+\\[[^]]+\\][[:space:]]*\n" + end t) + (let ((line (line-number-at-pos (match-beginning 0)))) + (if tc-check-only + (push (list :kind 'bogus-log + :file tc-current-file + :line line + :detail (string-trim (match-string 0))) + tc-issues) + (delete-region (match-beginning 0) (match-end 0)) + (cl-incf tc-fixes) + (push (list :kind 'bogus-log-fixed + :file tc-current-file + :line line + :detail (string-trim (match-string 0))) + tc-issues))))))) + +(defun tc-detect-orphan-planning-in-entry () + "Flag entries with a body DEADLINE/SCHEDULED that org-entry-get can't read. +That means the planning line isn't in canonical position, so org-mode's +agenda + scheduling machinery won't see it." + (let* ((line (line-number-at-pos)) + (heading (org-get-heading t t t t)) + (dl-canonical (org-entry-get (point) "DEADLINE")) + (sc-canonical (org-entry-get (point) "SCHEDULED")) + (start (save-excursion (org-end-of-meta-data t) (point))) + (end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point))) + (body (buffer-substring-no-properties start end))) + (when (and (not dl-canonical) + (string-match "^[[:space:]]*DEADLINE:[[:space:]]*\\(<[^>]+>\\)" body)) + (push (list :kind 'orphan-deadline + :file tc-current-file + :line line + :heading heading + :detail (match-string 1 body)) + tc-issues)) + (when (and (not sc-canonical) + (string-match "^[[:space:]]*SCHEDULED:[[:space:]]*\\(<[^>]+>\\)" body)) + (push (list :kind 'orphan-scheduled + :file tc-current-file + :line line + :heading heading + :detail (match-string 1 body)) + tc-issues)))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done mode + +(defun tc--find-section (substring) + "Buffer position (beginning of line) of the unique level-1 heading whose +stripped text contains SUBSTRING, case-insensitively. +Return nil if there is no such heading, or the symbol `multiple' if there is +more than one." + (let ((needle (regexp-quote (downcase substring))) + (matches nil)) + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^\\* " nil t) + (let* ((pos (match-beginning 0)) + (text (downcase (or (save-excursion (goto-char pos) + (org-get-heading t t t t)) + "")))) + (when (string-match-p needle text) + (push pos matches))))) + (cond ((null matches) nil) + ((cdr matches) 'multiple) + (t (car matches))))) + +(defun tc--subtree-end (heading-bol level) + "Beginning of the first heading at level <= LEVEL after HEADING-BOL, +or `point-max' if there is none." + (save-excursion + (goto-char heading-bol) + (forward-line 1) + (let (found) + (while (and (not found) (re-search-forward "^\\(\\*+\\)[ \t]" nil t)) + (when (<= (length (match-string 1)) level) + (setq found (match-beginning 0)))) + (or found (point-max))))) + +(defun tc--subtree-region () + "Return (BEG . END) for the subtree whose heading the point is on. +BEG is the beginning of the heading line; END is the beginning of the next +heading at the same or a shallower level, or `point-max'." + (org-back-to-heading t) + (let ((beg (line-beginning-position)) + (level (org-current-level))) + (cons beg (tc--subtree-end beg level)))) + +(defun tc--done-level-2-children (section-bol) + "List of heading positions (beginning of line) for the direct level-2 +children of the level-1 section heading at SECTION-BOL whose TODO state is in +`tc-done-states', in document order." + (save-excursion + (goto-char section-bol) + (forward-line 1) + (let ((positions nil) + (stop nil)) + (while (and (not stop) (re-search-forward "^\\(\\*+\\)[ \t]" nil t)) + (let ((lvl (length (match-string 1))) + (hpos (match-beginning 0))) + (cond + ((<= lvl 1) (setq stop t)) ; reached the next level-1 section + ((= lvl 2) + (when (member (save-excursion (goto-char hpos) (org-get-todo-state)) + tc-done-states) + (push hpos positions))) + ;; lvl > 2: a deeper descendant — leave it alone + ))) + (nreverse positions)))) + +(defun tc--archive-skip (detail) + (push (list :kind 'archive-skip :file tc-current-file :detail detail) tc-issues)) + +(defun tc-archive-done-in-file () + "Move level-2 DONE/CANCELLED subtrees from the \"Open Work\" section into the +\"Resolved\" section of the current buffer. Under `tc-check-only' the moves +are reported but not performed." + (let ((open (tc--find-section "open work")) + (res (tc--find-section "resolved"))) + (cond + ((null open) (tc--archive-skip "no level-1 heading containing \"Open Work\"")) + ((eq open 'multiple) (tc--archive-skip "more than one level-1 heading contains \"Open Work\"")) + ((null res) (tc--archive-skip "no level-1 heading containing \"Resolved\"")) + ((eq res 'multiple) (tc--archive-skip "more than one level-1 heading contains \"Resolved\"")) + ((= open res) (tc--archive-skip "the same heading matches both \"Open Work\" and \"Resolved\"")) + (tc-check-only + (save-excursion + (dolist (pos (tc--done-level-2-children open)) + (goto-char pos) + (push (list :kind 'archive-would :file tc-current-file + :line (line-number-at-pos) + :heading (org-get-heading t t t t)) + tc-issues) + (cl-incf tc-archived)))) + (t + (catch 'done + (while t + (let* ((open* (tc--find-section "open work")) + (targets (and (integerp open*) (tc--done-level-2-children open*)))) + (unless targets (throw 'done nil)) + (goto-char (car targets)) + (let* ((region (tc--subtree-region)) + (beg (car region)) + (end (cdr region)) + (heading (save-excursion (goto-char beg) (org-get-heading t t t t))) + (line (line-number-at-pos beg)) + ;; Normalize the trailing separator to a single newline so + ;; moved subtrees don't drag blank lines into "Resolved". + (text (concat (string-trim-right (buffer-substring-no-properties beg end) + "[ \t\n]+") + "\n"))) + (delete-region beg end) + (let* ((res* (tc--find-section "resolved")) + (ins (tc--subtree-end res* 1))) + (goto-char ins) + (unless (bolp) (insert "\n")) + (insert text) + (unless (bolp) (insert "\n"))) + (cl-incf tc-archived) + (push (list :kind 'archive-moved :file tc-current-file + :line line :heading heading) + tc-issues))))))))) + +;;; --------------------------------------------------------------------------- +;;; --sync-child-priority mode + +(defun tc--heading-priority-letter () + "Return the priority letter (a character) on the heading at point, or nil +if the heading has no priority cookie in canonical position. + +Uses `org-heading-components' rather than regexing the whole line, because +the cookie must sit right after the stars or the optional TODO keyword — +otherwise `[#X]'-shaped text inside the title (a dated log entry like +\"... reprioritized =[#D]= → =[#B]= to match parent\") gets misread as a +real cookie." + (save-excursion + (org-back-to-heading t) + (nth 3 (org-heading-components)))) + +(defun tc--priority-lower-p (child parent) + "Non-nil when CHILD priority letter ranks lower than PARENT — i.e. later in +the alphabet, since A is highest in org's default priority scheme." + (and child parent (> child parent))) + +(defun tc--heading-has-no-sync-tag-p () + "Non-nil when the heading line at point carries the literal substring +`:no-sync:'. Uses a literal regex match rather than `org-get-tags' because +org's default tag character class (`org-tag-re') excludes hyphens — +`no-sync' isn't recognized as a real org tag in batch mode unless the user +has extended that regex. The literal `:no-sync:' is what wrap-up sessions +actually type, so match it directly anywhere on the heading line; the +heading line is scoped narrowly enough that a false-positive match in title +text is unlikely, and the cost would only be skipping a bump." + (save-excursion + (org-back-to-heading t) + (let ((line (buffer-substring-no-properties + (line-beginning-position) (line-end-position)))) + (string-match-p (format ":%s:" (regexp-quote tc-no-sync-tag)) + line)))) + +(defun tc--ancestor-or-self-has-no-sync-tag-p () + "Non-nil when the heading at point, or any strict ancestor, carries the +literal `:no-sync:' tag on its own heading line. Walks up the outline +chain via `org-up-heading-safe', which returns nil at the top level +instead of erroring." + (save-excursion + (org-back-to-heading t) + (catch 'found + (when (tc--heading-has-no-sync-tag-p) + (throw 'found t)) + (while (org-up-heading-safe) + (when (tc--heading-has-no-sync-tag-p) + (throw 'found t))) + nil))) + +(defun tc--set-heading-priority (letter) + "Rewrite the priority cookie on the heading at point to LETTER (a character)." + (save-excursion + (org-back-to-heading t) + (let ((eol (line-end-position))) + (when (re-search-forward tc--priority-cookie-regexp eol t) + (replace-match (format "[#%c]" letter) t t))))) + +(defun tc--direct-children-of-current-heading () + "Return heading positions (beginning of line) of the direct children of the +heading at point, in document order. Direct children = headings exactly one +level deeper than the parent." + (save-excursion + (org-back-to-heading t) + (let* ((parent-level (org-current-level)) + (child-level (1+ parent-level)) + (subtree-end (save-excursion (org-end-of-subtree t t) (point))) + (positions nil)) + (forward-line 1) + (while (re-search-forward "^\\(\\*+\\)[ \t]" subtree-end t) + (let ((lvl (length (match-string 1))) + (pos (match-beginning 0))) + (when (= lvl child-level) + (push pos positions)))) + (nreverse positions)))) + +(defun tc-sync-child-priority-at-heading () + "If the heading at point carries a priority cookie, bump any direct child +heading whose own priority is lower, skipping children whose own heading +or any ancestor carries `tc-no-sync-tag'. A priority-less parent is a +no-op; priority-less children are left untouched (down-only does not +invent priorities)." + (let ((parent (tc--heading-priority-letter))) + (when parent + (let ((parent-heading (org-get-heading t t t t))) + (dolist (child-pos (tc--direct-children-of-current-heading)) + (save-excursion + (goto-char child-pos) + (let ((child (tc--heading-priority-letter))) + (when (and child + (tc--priority-lower-p child parent) + (not (tc--ancestor-or-self-has-no-sync-tag-p))) + (let ((child-heading (org-get-heading t t t t)) + (child-line (line-number-at-pos))) + (cl-incf tc-bumped) + (if tc-check-only + (push (list :kind 'sync-would + :file tc-current-file + :line child-line + :child-heading child-heading + :parent-heading parent-heading + :from (char-to-string child) + :to (char-to-string parent)) + tc-issues) + (tc--set-heading-priority parent) + (push (list :kind 'sync-bumped + :file tc-current-file + :line child-line + :child-heading child-heading + :parent-heading parent-heading + :from (char-to-string child) + :to (char-to-string parent)) + tc-issues))))))))))) + +(defun tc-sync-child-priority-in-file () + "Walk every heading in the buffer and run `tc-sync-child-priority-at-heading'. +`org-map-entries' visits headings in document order, so parents are bumped +before their descendants — a [#A] → [#B] → [#D] chain collapses in one pass." + (org-map-entries #'tc-sync-child-priority-at-heading nil 'file)) + +;;; --------------------------------------------------------------------------- +;;; Driver + reporting + +(defun tc-process-file (file) + (setq tc-current-file (file-name-nondirectory file)) + (with-current-buffer (find-file-noselect file) + (org-mode) + (cond + (tc-archive-done + (tc-archive-done-in-file)) + (tc-sync-child-priority + (tc-sync-child-priority-in-file)) + (t + ;; Pass 1: auto-fix bogus state logs (or report under --check). + (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file) + ;; Pass 2: detect orphan planning lines (always report-only). + (org-map-entries #'tc-detect-orphan-planning-in-entry nil 'file))) + (when (and (not tc-check-only) (buffer-modified-p)) + (save-buffer)))) + +(defun tc--emit-archive-report () + (princ (format "todo-cleanup --archive-done: %d subtree(s) %s%s\n" + tc-archived + (if tc-check-only "would move" "moved") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ('archive-skip + (princ (format " skipped %s: %s\n" (plist-get i :file) (plist-get i :detail)))) + ((or 'archive-moved 'archive-would) + (princ (format " %s:%d: %s %s\n" + (plist-get i :file) + (plist-get i :line) + (if tc-check-only "would move" "moved") + (plist-get i :heading))))))) + +(defun tc--emit-hygiene-report () + (princ (format "todo-cleanup: %d fix(es) applied%s\n" + tc-fixes + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (let ((orphans (cl-remove-if-not (lambda (i) (memq (plist-get i :kind) + '(orphan-deadline + orphan-scheduled))) + tc-issues)) + (logs (cl-remove-if-not (lambda (i) (memq (plist-get i :kind) + '(bogus-log + bogus-log-fixed))) + tc-issues))) + (when logs + (princ (format " Bogus state-log lines (%s):\n" + (if tc-check-only "would delete" "deleted"))) + (dolist (i (nreverse logs)) + (princ (format " %s:%d: %s\n" + (plist-get i :file) + (plist-get i :line) + (plist-get i :detail))))) + (when orphans + (princ (format " Orphan planning lines needing manual fix (%d):\n" (length orphans))) + (dolist (i (nreverse orphans)) + (princ (format " %s:%d: %s — %s in body\n" + (plist-get i :file) + (plist-get i :line) + (plist-get i :heading) + (plist-get i :detail))))))) + +(defun tc--emit-sync-report () + (princ (format "todo-cleanup --sync-child-priority: %d child priority cookie(s) %s%s\n" + tc-bumped + (if tc-check-only "would bump" "bumped") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ((or 'sync-bumped 'sync-would) + (princ (format " %s:%d: [#%s] → [#%s] %s (under: %s)\n" + (plist-get i :file) + (plist-get i :line) + (plist-get i :from) + (plist-get i :to) + (plist-get i :child-heading) + (plist-get i :parent-heading))))))) + +(defun tc-emit-report () + (cond (tc-archive-done (tc--emit-archive-report)) + (tc-sync-child-priority (tc--emit-sync-report)) + (t (tc--emit-hygiene-report)))) + +(defun tc-main () + ;; Strip our flags from `command-line-args-left' so emacs's own arg parser + ;; doesn't see them after this returns. + (when (member "--check" command-line-args-left) + (setq tc-check-only t) + (setq command-line-args-left (delete "--check" command-line-args-left))) + (when (member "--archive-done" command-line-args-left) + (setq tc-archive-done t) + (setq command-line-args-left (delete "--archive-done" command-line-args-left))) + (when (member "--sync-child-priority" command-line-args-left) + (setq tc-sync-child-priority t) + (setq command-line-args-left (delete "--sync-child-priority" command-line-args-left))) + ;; --check-child-priority is the report-only alias for + ;; `--sync-child-priority --check'. + (when (member "--check-child-priority" command-line-args-left) + (setq tc-sync-child-priority t tc-check-only t) + (setq command-line-args-left (delete "--check-child-priority" command-line-args-left))) + (if (null command-line-args-left) + (progn + (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --sync-child-priority | --check-child-priority] FILE...\n") + (kill-emacs 1)) + (let ((files command-line-args-left)) + (setq command-line-args-left nil) + (dolist (file files) + (when (file-readable-p file) + (tc-process-file file))) + (tc-emit-report)))) + +(defun tc--cli-invocation-p () + "Non-nil when the trailing command-line arguments look like a real +todo-cleanup invocation: only recognized flags and/or readable file paths. +Lets the ERT suite `require' this file without triggering the CLI dispatch — +during a test run the trailing args are things like `-f +ert-run-tests-batch-and-exit'." + (and command-line-args-left + (cl-every (lambda (a) + (cond ((member a '("--check" + "--archive-done" + "--sync-child-priority" + "--check-child-priority")) + t) + ((string-prefix-p "-" a) nil) + (t (file-readable-p a)))) + command-line-args-left))) + +(when (and noninteractive (tc--cli-invocation-p)) + (tc-main)) + +(provide 'todo-cleanup) +;;; todo-cleanup.el ends here diff --git a/claude-templates/.ai/someday-maybe.org b/claude-templates/.ai/someday-maybe.org new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/claude-templates/.ai/someday-maybe.org diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org new file mode 100644 index 0000000..de1737b --- /dev/null +++ b/claude-templates/.ai/workflows/INDEX.org @@ -0,0 +1,79 @@ +#+TITLE: Workflow Index +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-04-25 + +* Purpose + +Single-source catalog of every workflow in this directory, with the trigger phrases that should invoke it. Read this file before =ls=-ing the workflows directory — it tells you which file handles which phrase, so you don't have to read each workflow's "When to Use" section to route correctly. + +* Drift Check + +This index must list every =.org= file in =.ai/workflows/= except this one. Startup verifies the index matches the directory and flags drift (missing entries or stale entries pointing at deleted files). + +* Catalog + +** Session lifecycle + +- =startup.org= — runs automatically at session start. No manual trigger. +- =first-session.org= — initialize =.ai/= for a brand-new project. + - Triggers: "this is a new project", "let's set this project up". Auto-runs if =.ai/sessions/= is empty. +- =wrap-it-up.org= — end-of-session: write summary, archive, commit, push. + - Triggers: "wrap it up", "that's a wrap", "let's call it a wrap" +- =retrospective.org= — post-mortem after a tough session. + - Triggers: "let's do a retrospective", "retrospective time" + +** Tasks and planning + +- =task-review.org= — list all open tasks (list mode) or pick the next task (next mode). + - Triggers: "what's next", "what should I work on", "list open tasks", "show me all tasks", "what's on my plate", "task review", "show me my tasks", "I need a recommendation" +- =daily-prep.org= — prep brief for the next workday. Two modes: full-prep (default) or standup-only. + - Full-prep triggers: "let's prep for tomorrow", "daily prep" + - Standup-only triggers: "what's my standup report", "let's do the daily standup report", "give me the standup brief" +- =triage-intake.org= — on-demand triage: scan every inbox source (DeepSat Gmail, personal Gmail, cmail/Proton, Slack, Linear, GitHub PRs, both calendars, recent =todo.org= edits), surface what's moved, run the Linear Dev-Review sweep, mark *all* unread INBOX email across the three accounts and every touched Slack conversation as read. Lighter scope than =daily-prep.org='s triage section. Projects that want it called from =wrap-it-up.org= (or elsewhere) can opt in via a =.ai/project-workflows/<name>.org= extension. + - Triggers: "do a triage intake", "triage intake", "what's moved?", "what's new?", "check for movement" +- =journal-entry.org= — capture a daily journal entry. + - Triggers: "let's do a journal entry", "create a journal entry" +- =clean-todo.org= — tidy =todo.org=: hygiene pass + =--archive-done=, then summarize. Wrap-up does this automatically; this is the manual entry point. + - Triggers: "clean up todo.org", "clean-todo", "tidy the todo file", "archive the done items in todo.org", "run the todo cleanup" + +** Calendar + +- =add-calendar-event.org= — create a calendar event. + - Triggers: "create an event", "add appointment", "schedule a meeting", "add to my calendar", "calendar event for..." +- =read-calendar-events.org= — read / summarize calendar. + - Triggers: "what's on my calendar", "show me appointments", "summarize my schedule", "what do I have today", "calendar for this week", "any meetings tomorrow" +- =edit-calendar-event.org= — modify an existing event. + - Triggers: "edit the meeting", "change my appointment", "reschedule", "update the event", "move my appointment" +- =delete-calendar-event.org= — cancel / remove an event. + - Triggers: "delete the meeting", "cancel my appointment", "remove the event", "clear my calendar for..." + +** Email + +- =sync-email.org= — pull and index new mail. + - Triggers: "sync email", "sync mail", "pull new mail", "check for new email" +- =find-email.org= — search local maildir for emails. + - Triggers: "find email about [topic]", "search for emails from [person]", "do I have an email about [subject]?", "look for [shipping/receipt/confirmation] email" +- =summarize-emails.org= — filter noise, summarize what matters. + - Triggers: "summarize my emails", "what emails do I have", "anything important in my inbox", "email summary", "any unread emails", "any starred emails", "emails from [person]", "emails also sent to [person]" +- =extract-email.org= — extract content / attachments from an inbox EML. + - Triggers: "extract the email", "get the attachment from [email]", "pull the info from [email]", "process the email in inbox" +- =send-email.org= — compose and send an email. + - Triggers: "send an email", "email workflow", "email [person] about [topic]", "send [file] to [person]" +- =email-assembly.org= — gather documents into an email package. + - Triggers: "assemble an email", "email assembly workflow", "gather documents for an email", "I need to send [person] some documents" + +** Tools and meta + +- =process-meeting-transcript.org= — record → transcript → labeled archive. + - Triggers: "process the transcript", "process the recording". Auto: new files in =~/sync/recordings/=. +- =page-me.org= — set a timed notification. + - Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm") +- =status-check.org= — proactive long-running-job updates. + - Triggers: "keep me posted on this", "provide status checks on this job", "let me know when it's done", "monitor this for me". Auto: any job estimated 10+ min. +- =create-workflow.org= — define a new workflow. + - Triggers: "let's create/define/design a workflow for [activity]", or unmatched workflow request after this index returns no hit. +- =cross-agent-comms.org= — protocol for cross-project agent coordination via =inbox/from-agents/= (file-based IPC, GPG-signed, supports cross-machine over Tailscale). Auto: when =cross-agent-watch= detects a new inbound message, or when an agent decides to initiate a cross-project conversation. Operational scripts (=cross-agent-send=, =-recv=, =-watch=, =-status=, =-discover=, =-halt=, =-resume=) and their READMEs live at =.ai/scripts/cross-agent-comms/=. + +* Living Document + +Add a row when a new workflow lands in =.ai/workflows/=. Remove the row when a workflow is deleted. Update triggers when a workflow's "When to Use" section changes. The startup drift check is the safety net — it catches forgotten updates but doesn't substitute for keeping this file current. diff --git a/claude-templates/.ai/workflows/add-calendar-event.org b/claude-templates/.ai/workflows/add-calendar-event.org new file mode 100644 index 0000000..2650fb7 --- /dev/null +++ b/claude-templates/.ai/workflows/add-calendar-event.org @@ -0,0 +1,190 @@ +#+TITLE: Add Calendar Event Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-01 + +* Overview + +Workflow for creating calendar events. Uses the Google Calendar MCP server (preferred) or gcalcli (fallback, personal account only). + +* Triggers + +- "create an event" +- "add appointment" +- "schedule a meeting" +- "add to my calendar" +- "calendar event for..." + +* Prerequisites + +- Google Calendar MCP server configured and authenticated (=@cocal/google-calendar-mcp=) +- Two accounts available: =personal= (Craig Google) and =work= (Craig Deepsat) +- Fallback: gcalcli installed (personal account only) + +* CRITICAL: Check All Calendars Before Scheduling + +Before creating any event, ALWAYS check for conflicts across ALL calendars. Use the MCP =get-freebusy= tool to check availability, or query multiple sources: + +1. *MCP server* — check both personal and work accounts via =list-events= or =get-freebusy= +2. *Emacs org files* — for Proton calendar (not accessible via MCP or gcalcli): + +#+begin_src bash +grep "2026-02-18" ~/.emacs.d/data/pcal.org # Proton calendar +#+end_src + +Always verify the time slot is free across all calendars before creating. + +* Workflow Steps + +** 1. Parse Natural Language Input + +Interpret the user's request to extract: +- Event title +- Date/time (natural language like "tomorrow 3pm", "next Tuesday at 2") +- Any mentioned location +- Any mentioned description + +Examples: +- "Create an event tomorrow at 5pm called Grocery Shopping" +- "Add a meeting with Bob on Friday at 10am" +- "Schedule dentist appointment next Wednesday at 2pm at Downtown Dental" + +** 2. Apply Defaults + +| Field | Default Value | +|------------+----------------------------------| +| Calendar | Craig (default Google Calendar) | +| Reminders | 5 minutes before, at event time | +| Duration | NONE - always ask user | +| Location | None (optional) | + +** 3. Gather Missing Information + +*Always ask for:* +- Duration (required, no default) + +*Ask if relevant:* +- Location (if not provided and seems like an in-person event) + +*Never assume:* +- Duration - this must always be explicitly confirmed + +** 4. Show Event Summary + +Present the event in plain English (NOT the gcalcli command): + +#+begin_example +Event: Grocery Shopping +When: Tomorrow (Feb 2) at 5:00 PM +Duration: 1 hour +Location: (none) +Reminders: 5 min before, at event time +Calendar: Personal +#+end_example + +** 5. Explicit Confirmation + +Ask: "Create this event? (yes/no)" + +*Do NOT create the event until user confirms.* + +** 6. Execute + +Once confirmed, create the event. + +*** MCP (preferred) + +Use the =create-event= MCP tool: +- =account_id=: "personal" (default) or "work" +- =calendar_id=: calendar name or ID (default: primary) +- =summary=: event title +- =start=, =end=: ISO 8601 datetime (e.g., "2026-02-15T14:00:00-06:00") +- =location=: location string (optional) +- =description=: event notes (optional) +- =reminders=: custom reminders (optional) + +*** gcalcli (fallback, personal account only) + +#+begin_src bash +gcalcli --calendar "Calendar Name" add \ + --title "Event Title" \ + --when "date and time" \ + --duration MINUTES \ + --where "Location" \ + --description "Description" \ + --reminder 5 \ + --reminder 0 \ + --noprompt +#+end_src + +** 7. Verify + +Confirm the event was created: + +*** MCP +Use =search-events= or =list-events= to verify. + +*** gcalcli (fallback) +#+begin_src bash +gcalcli --calendar "Calendar Name" search "Event Title" +#+end_src + +Report success or failure to user. + +* Calendars + +| Calendar | Access | Account | Notes | +|---------------------------+--------+----------+--------------------------------| +| Craig Google | owner | personal | Default — use for most events | +| Christine | owner | personal | Christine's calendar | +| Craig Deepsat | owner | work | DeepSat work calendar | +| Todoist | owner | personal | Todoist integration | +| Craig Jennings (TripIt) | reader | personal | View only, no create | +| Holidays in United States | reader | personal | View only | +| Craig Proton | reader | personal | View only (no API access) | + +* Time Formats + +MCP tools use ISO 8601: =2026-02-15T14:00:00-06:00= + +gcalcli accepts natural language times: +- "tomorrow 3pm" +- "next Tuesday at 2" +- "2026-02-15 14:00" +- "Feb 15 2pm" +- "today 5pm" + +* Duration + +MCP uses explicit start/end times (no duration field — calculate end time from start + duration). + +gcalcli duration shortcuts: + +| Input | Minutes | +|--------+---------| +| 30m | 30 | +| 1h | 60 | +| 1.5h | 90 | +| 2h | 120 | +| 90 | 90 | + +* Error Handling + +** MCP Authentication Error +Use =manage-accounts= MCP tool with =action: "add"= and the account nickname to re-authenticate. + +** gcalcli Authentication Error +Run =gcalcli init= to re-authenticate. + +** Calendar Not Found +MCP: Use =list-calendars= to see available calendars. +gcalcli: Use =gcalcli list=. + +** Invalid Time Format +MCP: Use ISO 8601 format: =YYYY-MM-DDTHH:MM:SS±HH:MM= +gcalcli: Use explicit date format: =YYYY-MM-DD HH:MM= + +* Related + +- [[file:read-calendar-events.org][Read Calendar Events]] - view events +- [[file:edit-calendar-event.org][Edit Calendar Event]] - modify events +- [[file:delete-calendar-event.org][Delete Calendar Event]] - remove events diff --git a/claude-templates/.ai/workflows/clean-todo.org b/claude-templates/.ai/workflows/clean-todo.org new file mode 100644 index 0000000..dd33056 --- /dev/null +++ b/claude-templates/.ai/workflows/clean-todo.org @@ -0,0 +1,58 @@ +#+TITLE: Clean-Todo Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-11 + +* Overview + +On-demand cleanup of the project's =todo.org=: run the hygiene pass, then archive completed work, then report what changed. The wrap-up workflow already does both passes at session end; this workflow is the manual entry point — invoke it any time the todo file needs a tidy, without waiting for a wrap-up. + +* When to Use This Workflow + +When Craig says: +- "clean up todo.org" / "clean-todo" / "tidy the todo file" +- "archive the done items in todo.org" +- "run the todo cleanup" + +Requires a =todo.org= at the project root. If there isn't one, say so and stop. + +* The Workflow + +** Step 1: Hygiene pass + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el todo.org +#+end_src + +Deletes bogus =- State "X" from "X" [date]= log lines (state didn't actually change — these wedge between the heading and =DEADLINE:=/=SCHEDULED:= and break agenda parsing) and reports "orphan planning lines" (a body =DEADLINE:=/=SCHEDULED:= that =org-entry-get= can't read because it's out of canonical position — not auto-rewritten; surface for manual fix). Fast and idempotent. Capture the output. + +To preview without writing, run =--check= first: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org=. + +** Step 2: Archive completed work + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org +#+end_src + +Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of the "Open Work" section into the "Resolved" section, subtree intact. The two sections are matched by a unique level-1 heading containing "Open Work" (case-insensitive) and one containing "Resolved" — if either is missing or ambiguous, the file is skipped with a message, no crash. Only direct level-2 children move; a DONE entry nested under an open parent stays put. Idempotent. Capture the output. + +To preview the moves without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org=. + +** Step 3: Summarize + +Report to Craig from the two captured outputs: +- Hygiene: how many bogus state-log lines were deleted; any orphan-planning warnings (file:line + heading), or "none". +- Archive: how many subtrees moved and which (heading + line), or "nothing to move" / the skip reason if a section was missing or ambiguous. +- If the file changed, note that =todo.org= now has an uncommitted edit — review =git diff -- todo.org= and commit it (in this repo's commit style) if it looks right. If nothing changed, say so and stop. + +Don't auto-commit. The summary is the review point; Craig decides whether the diff goes in. + +* Principles + +- *Both passes apply, not just preview.* The workflow is invoked because cleanup is wanted. Use the =--check= variants only when Craig asks for a dry run. +- *Two passes, two invocations.* =--archive-done= is its own mode and does not run the hygiene pass; run both. +- *Never auto-commit todo.org.* Surface the diff and let Craig commit it. The cleanup is a working-tree change, fully reversible until committed. +- *Trust the script.* It's fast and idempotent; if there's nothing to do, it reports zero and exits clean. No pre-checks. + +* Living Document + +Update this workflow if =todo-cleanup.el= grows new modes or the section-matching rules change. diff --git a/claude-templates/.ai/workflows/create-workflow.org b/claude-templates/.ai/workflows/create-workflow.org new file mode 100644 index 0000000..e6587c8 --- /dev/null +++ b/claude-templates/.ai/workflows/create-workflow.org @@ -0,0 +1,360 @@ +#+TITLE: Creating New Workflows +#+AUTHOR: Craig Jennings +#+DATE: 2025-11-01 + +* Overview + +This document describes the meta-workflow for creating new workflows. When we identify a repetitive workflow or collaborative pattern, we use this process to formalize it into a documented workflow that we can reference and reuse. + +Note the definitions: "sessions" are the time Claude spends with the user, i.e., the user starts a "session" with Claude, does some work, then ends the "session". A workflow is a routine or pattern of doing tasks within a session to accomplish a goal. + +Workflows are living documents that capture how we work together on specific types of tasks. They build our shared vocabulary and enable efficient collaboration across multiple work sessions. + +* Problem We're Solving + +Without a formal workflow creation process, we encounter several issues: + +** Inefficient Use of Intelligence +- Craig leads the process based solely on his knowledge +- We don't leverage Claude's expertise to improve or validate the approach +- Miss opportunities to apply software engineering and process best practices + +** Time Waste and Repetition +- Craig must re-explain the workflow each time we work together +- No persistent memory of how we've agreed to work +- Each session starts from scratch instead of building on previous work + +** Error-Prone Execution +- Important steps may be forgotten or omitted +- No checklist to verify completeness +- Mistakes lead to incomplete work or failed goals + +** Missed Learning Opportunities +- Don't capture lessons learned from our collaboration +- Can't improve processes based on what works/doesn't work +- Lose insights that emerge during execution + +** Limited Shared Vocabulary +- No deep, documented understanding of what terms mean +- "Let's do a refactor workflow" has no precise definition +- Can't efficiently communicate about workflows + +*Impact:* Inefficiency, errors, and lost opportunity to continuously improve our collaborative workflows. + +* Exit Criteria + +We know a workflow definition is complete when: + +1. **Information is logically arranged** - The structure makes sense and flows naturally +2. **Both parties understand how to work together** - We can articulate the workflow +3. **Agreement on effectiveness** - We both agree that following this workflow will lead to exit criteria and resolve the stated problem +4. **Tasks are clearly defined** - Steps are actionable, not vague +5. **Problem resolution path** - Completing the tasks either: + - Fixes the problem permanently, OR + - Provides a process for keeping the problem at bay + +*Measurable validation:* +- Can we both articulate the workflow without referring to the document? +- Do we agree it will solve the problem? +- Are the tasks actionable enough to start immediately? +- Does the workflow get used soon after creation (validation by execution)? + +* When to Use This Workflow + +Trigger this workflow creation workflow when: + +- You notice a repetitive workflow that keeps coming up +- A collaborative pattern emerges that would benefit from documentation +- Craig says "let's create/define/design a workflow for [activity]" +- You identify a new type of work that doesn't fit existing workflows +- An existing workflow needs significant restructuring (treat as creating a new one) + +Examples: +- "Let's create a workflow where we inbox zero" +- "We should define a code review workflow" +- "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 + +Walk through these four core questions collaboratively. Take notes on the answers. + +*IMPORTANT: Save answers as you go!* + +The Q&A phase can take time—Craig may need to think through answers, and discussions can be lengthy. To prevent data loss from terminal crashes or process quits: + +1. Create a draft file at =.ai/workflows/[name]-draft.org= after deciding on the name +2. After each question is answered, save the Q&A content to the draft file +3. If workflow is interrupted, you can resume from the saved answers +4. Once complete, the draft becomes the final workflow document + +This protects against losing substantial thinking work if the workflow is interrupted. + +*** Question 1: What problem are we solving in this type of workflow? + +Ask Craig: "What problem are we solving in this type of workflow? What would happen without this workflow?" + +The answer reveals: +- Overview and goal of the workflow +- Why this work matters (motivation) +- Impact/priority compared to other work +- What happens if we don't do this work + +Example from refactor workflow: +#+begin_quote +"My Emacs configuration isn't resilient enough. There's lots of custom code, and I'm even developing some as Emacs packages. Yet Emacs is my most-used software, so when Emacs breaks, I become unproductive. I need to make Emacs more resilient through good unit tests and refactoring." +#+end_quote + +*** Question 2: How do we know when we're done? + +Ask Craig: "How do we know when we're done?" + +The answer reveals: +- Exit criteria +- Results/completion criteria +- Measurable outcomes + +*Your role:* +- Push back if the answer is vague or unmeasurable +- Propose specific measurements based on context +- Iterate together until criteria are clear +- Fallback (hopefully rare): "when Craig says we're done" + +Example from refactor workflow: +#+begin_quote +"When we've reviewed all methods, decided which to test and refactor, run all tests, and fixed all failures including bugs we find." +#+end_quote + +Claude might add: "How about a code coverage goal of 70%+?" + +*** Question 3: How do you see us working together in this kind of workflow? + +Ask Craig: "How do you see us working together in this kind of workflow?" + +The answer reveals: +- Steps or phases we'll go through +- The general approach to the work +- How tasks flow from one to another + +*Your role:* +- As steps emerge, ask yourself: + - "Do these steps lead to solving the real problem?" + - "What is missing from these steps?" +- If the answers aren't "yes" and "nothing", raise concerns +- Propose additions based on your knowledge +- Suggest concrete improvements + +Example from refactor workflow: +#+begin_quote +"We'll analyze test coverage, categorize functions by testability, write tests systematically using Normal/Boundary/Error categories, run tests, analyze failures, fix bugs, and repeat." +#+end_quote + +Claude might suggest: "Should we install a code coverage tool as part of this process?" + +*** Question 4: Are there any principles we should be following while doing this? + +Ask Craig: "Are there any principles we should be following while doing this kind of workflow?" + +The answer reveals: +- Principles to follow +- Decision frameworks +- Quality standards +- When to choose option A vs option B + +*Your role:* +- Think through all elements of the workflow +- Consider situations that may arise +- Identify what principles would guide decisions +- Suggest decision frameworks from your knowledge + +Example from refactor workflow: +#+begin_quote +Craig: "Treat all test code as production code - same engineering practices apply." + +Claude suggests: "Since we'll refactor methods mixing UI and logic, should we add a principle to separate them for testability?" +#+end_quote + +** Phase 2: Assess Completeness + +After the Q&A, ask together: + +1. **Do we have enough information to formulate steps/process?** + - If yes, proceed to Phase 3 + - If no, identify what's missing and discuss further + +2. **Do we agree following this approach will resolve/mitigate the problem?** + - Both parties must agree + - If not, identify concerns and iterate + +** Phase 3: Name the Workflow + +Decide on a name for this workflow. + +*Naming convention:* Action-oriented (verb form) +- Examples: "refactor", "inbox-zero", "create-workflow", "review-code" +- Why: Shorter, natural when saying "let's do a [name] workflow" +- Filename: =.ai/workflows/[name].org= + +** Phase 4: Document the Workflow + +Write the workflow file at =.ai/workflows/[name].org= using this structure: + +*** Recommended Structure +1. *Title and metadata* (=#+TITLE=, =#+AUTHOR=, =#+DATE=) +2. *Overview* - Brief description of the workflow +3. *Problem We're Solving* - From Q&A, with context and impact +4. *Exit Criteria* - Measurable outcomes, how we know we're done +5. *When to Use This Workflow* - Triggers, circumstances, examples +6. *Approach: How We Work Together* + - Phases/steps derived from Q&A + - Decision frameworks + - Concrete examples woven throughout +7. *Principles to Follow* - Guidelines from Q&A +8. *Living Document Notice* - Reminder to update with learnings + +*** Important Notes +- Weave concrete examples into sections (don't separate them) +- Use examples from actual workflows when available +- Make tasks actionable, not vague +- Include decision frameworks for common situations +- Note that this is a living document + +** Phase 5: Update Project State + +Update =notes.org=: +1. Add new workflow to "Available Workflows" section +2. Include brief description and reference to file +3. Note creation date + +Example entry: +#+begin_src org +,** inbox-zero +File: =.ai/workflows/inbox-zero.org= + +Workflow for processing inbox to zero: +1. [Brief workflow summary] +2. [Key steps] + +Created: 2025-11-01 +#+end_src + +** 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. + +- Schedule the workflow for immediate use +- Follow the documented workflow +- Note what works well +- Identify gaps or unclear areas +- Update the workflow document with learnings + +*This validates the workflow definition and ensures it's practical, not theoretical.* + +* Principles to Follow + +These principles guide us while creating new workflows: + +** Collaboration Through Discussion +- Be proactive about collaboration +- Suggest everything on your mind +- Ask all relevant questions +- Push back when something seems wrong, inconsistent, or unclear +- Misunderstandings are learning opportunities + +** Reviewing the Whole as Well as the Pieces +- May get into weeds while identifying each step +- Stop to look at the whole thing at the end +- Ask the big questions: Does this actually solve the problem? +- Verify all pieces connect logically + +** Concrete Over Abstract +- Use examples liberally within explanations +- Weave concrete examples into Q&A answers +- Don't just describe abstractly +- "When nil input crashes, ask..." is better than "handle edge cases" + +** Actionable Tasks Over Vague Direction +- Steps should be clear enough to know what to do next +- "Ask: how do you see us working together?" is actionable +- "Figure out the approach" is too vague +- Test: Could someone execute this without further explanation? + +** Validate Early +- "Use it soon afterward" catches problems early +- Don't let workflow definitions sit unused and untested +- Real execution reveals gaps that theory misses +- Update immediately based on first use + +** Decision Frameworks Over Rigid Steps +- Workflows are frameworks (principles + flexibility), not recipes +- Include principles that help case-by-case decisions +- "When X happens, ask Y" is a decision framework +- "Always do X" is too rigid for most workflows + +** Question Assumptions +- If something doesn't make sense, speak up +- If a step seems to skip something, point it out +- Better to question during creation than discover gaps during execution +- No assumption is too basic to verify + +* Living Document + +This is a living document. As we create new workflows and learn what works (and what doesn't), we update this file with: + +- New insights about workflow creation +- Improvements to the Q&A process +- Better examples +- Additional principles discovered +- Refinements to the structure + +Every time we create a workflow, we have an opportunity to improve this meta-process. + +** Updates and Learnings + +*** 2025-11-01: Save Q&A answers incrementally +*Learning:* During emacs-inbox-zero workflow creation, we discovered that Q&A discussions can be lengthy and make Craig think deeply. Terminal crashes or process quits can lose substantial work. + +*Improvement:* Added guidance in Phase 1 to create a draft file and save Q&A answers after each question. This protects against data loss and allows resuming interrupted workflows. + +*Impact:* Reduces risk of losing 10-15 minutes of thinking work if workflow is interrupted. + +*** 2025-11-01: Validation by execution works! +*Learning:* Immediately after creating the emacs-inbox-zero workflow, we validated it by actually running the workflow. This caught unclear areas and validated that the 10-minute target was realistic. + +*Key insight from validation:* When Craig provides useful context during workflows (impact estimates, theories, examples), that context should be captured in task descriptions. This wasn't obvious during workflow creation but became clear during execution. + +*Impact:* Validation catches what theory misses. Always use Phase 6 (validate by execution) soon after creating a workflow. + +* Example: Creating the "Create-Workflow" Workflow + +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 +- *Principles:* Collaboration through discussion, review the whole, concrete over abstract, actionable tasks, validate early, decision frameworks, question assumptions + +** The Result +We identified what was needed, collaborated on answers, and captured it in this document. Then we immediately used it to create the next workflow (validation). + +* Conclusion + +Creating workflows is a meta-skill that improves all our collaboration. By formalizing how we work together, we: + +- Build shared vocabulary +- Eliminate repeated explanations +- Capture lessons learned +- Enable continuous improvement +- Make our partnership more efficient + +Each new workflow we create adds to our collaborative toolkit and deepens our ability to work together effectively. + +*Remember:* Workflows are frameworks, not rigid recipes. They provide structure while allowing flexibility for case-by-case decisions. The goal is effectiveness, not perfection. diff --git a/claude-templates/.ai/workflows/cross-agent-comms.org b/claude-templates/.ai/workflows/cross-agent-comms.org new file mode 100644 index 0000000..ccf1739 --- /dev/null +++ b/claude-templates/.ai/workflows/cross-agent-comms.org @@ -0,0 +1,334 @@ +#+TITLE: Cross-Agent Communication Workflow (v5) +#+AUTHOR: Craig Jennings & Claude (homelab + career sessions) +#+DATE: 2026-04-27 +#+VERSION: 5 + +* Status + +Draft. Iterating between the homelab and career sessions through a multi-round design discussion. Awaiting Craig's review for promotion to =~/projects/claude-templates/.ai/workflows/=. + +v5 changes from v4: +- *Script absorption.* Seven operational scripts (=cross-agent-send=, =cross-agent-recv=, =cross-agent-watch=, =cross-agent-status=, =cross-agent-discover=, =cross-agent-halt=, =cross-agent-resume=) now own most implementation detail. Their READMEs are the operational source of truth. The spec stays declarative. +- *Failsafe halt.* Layered HALT-file mechanism stops all cross-agent activity on a machine within ~5 min, without visiting individual sessions or restarting Claude Code. =cross-agent-halt= and =cross-agent-resume= are the convenience entry points; every other component checks the HALT file independently. +- *Identity.* Messages are GPG-signed by sender and verified by receiver. Combined with POSIX permissions on =from-agents/= and Tailscale-level network auth, identity becomes a three-layer story. +- *Atomic writes.* Writers MUST use temp-file + rename. =cross-agent-send= handles this; the spec just states the contract. +- *Dedup.* Sequence-collision dedup is now binary SHA-256 equality, not a fuzzy ">90% match" threshold. +- *Cold-start handling.* Layered: =cross-agent-watch= (push notifications via =inotifywait=) is the primary mechanism; startup-workflow check and user-direct-injection are coverage layers. +- *Spec stays roughly the same length but does more protocol work.* Operational detail (rsync retry numbers, inotifywait recipes, peers.toml schema, GPG flags, dedup mechanics) moved to the script READMEs. The spec adds new protocol elements (identity layer, atomic-writes contract, SHA-256 dedup, =escalate= type, =RELEASE_STATUS= values, =REQUIRES_TOOLS= optional field) in the freed space. Total documentation surface (spec + seven READMEs ≈ 1000 lines) is larger than v4's 259 lines, but the spec and the READMEs serve different audiences — protocol-thinkers and CLI-users — and a reader of just the spec can comprehend the protocol without consulting any README. + +* When to use + +When two Claude sessions in different projects (same machine or different machines on the same Tailscale tailnet) need to coordinate on a shared task that one session can't complete alone — typically because one has tooling, context, or MCP access the other doesn't. + +Examples that fit: +- Session A asks session B to apply a workflow patch in B's project, then verify it. +- Session A runs a long task and needs session B to monitor results in B's domain. +- Two sessions co-design a workflow. + +Examples that don't fit: +- A simple file handoff that doesn't require iteration. +- A task one session can do alone. +- Cross-tailnet or cross-organization. The protocol is local-tailnet-scoped. + +* Protocol + +** File location + +Each project has =inbox/from-agents/= as its agent-comms mailbox. Create the directory if it doesn't exist; set permissions =chmod 700= and ownership to the user. + +- Sender writes to receiver's =inbox/from-agents/=. +- Receiver polls (or watches) =inbox/from-agents/=, *not* the parent =inbox/=. +- The parent =inbox/= stays reserved for human-triage items. +- Out-of-band artifacts (PDFs, datasets) live at =inbox/from-agents/artifacts/=. Reference by relative path in the message body. + +The user does NOT write directly to =from-agents/=. To inject input into a running conversation, the user tells one of the agents in that agent's session; the agent writes the input as a normal message attributed to the user. + +** File naming + +=YYYYMMDDTHHMMSSZ-from-<sender>-<short-conv-id>.org= + +- Timestamp is UTC ISO 8601 compact. The trailing =Z= is mandatory. +- =from-<sender>= prefix. +- =<short-conv-id>= is a stable kebab-case slug across the back-and-forth. Reusable across time; ordering relies on filename timestamps. + +Frontmatter =#+TIMESTAMP= carries the same instant in local time with explicit offset. The two MUST refer to the same instant. + +The implementation (=cross-agent-send=) generates the canonical filename from the message's frontmatter (=CONVERSATION_ID=, current UTC time) and the sender's project context. Senders supply only the message body file; the script handles naming. Senders MUST NOT pre-name files in this format and pass them through; the script overwrites with its own canonical name to ensure consistency and enable the sender-side max-seen sequence-collision-reduction scan. + +GPG signatures live in a sibling file =YYYYMMDDTHHMMSSZ-from-<sender>-<short-conv-id>.org.asc=. Receivers verify before processing. See =* Writes are atomic= for the two-file delivery ordering rule. + +** Frontmatter + +Required: + +#+begin_example +#+TITLE: <human-readable subject> +#+CONVERSATION_ID: <stable across the thread> +#+MESSAGE_TYPE: <see types below> +#+SEQUENCE: <integer hint> +#+TIMESTAMP: <ISO 8601 with explicit offset> +#+PROTOCOL_VERSION: 5 +#+end_example + +Optional: + +#+begin_example +#+REQUIRES_TOOLS: <comma-separated tool/MCP slugs, e.g. gmail-mcp, slack-mcp> +#+RELEASE_STATUS: <see release-statuses; valid only on MESSAGE_TYPE: release> +#+WORKFLOW_VERSION: <sender's version of cross-agent-comms.org; informational only in v5 — no enforcement> +#+end_example + +Receiver sanity-checks frontmatter before acting. Missing or malformed frontmatter → surface to user, don't proceed. Mismatched =PROTOCOL_VERSION= → receiver writes a =query= asking the originator to upgrade. + +** Identity + +Messages are GPG-signed by the sender. Receivers verify the detached signature before processing the message body. + +The implementation (=cross-agent-send=) signs automatically with the sender's configured key (the user's primary GPG key by default; configurable via =--key= flag or environment). Receivers verify automatically against the keys in their GPG keyring. + +Identity is a three-layer story: + +1. *Tailscale layer.* Only tailnet members can reach the rsync-over-SSH endpoint at all. +2. *POSIX layer.* =chmod 700= on =from-agents/= means only processes running as the directory's owner can write. +3. *GPG layer.* Sender's signature on each message proves the message originated from a process holding the key. + +Three independent layers. Per-user GPG (using existing keys) gives a correctness check more than a security boundary — unsigned messages are almost certainly bugs, not attackers. That's still load-bearing. + +** Writes are atomic + +Writers MUST use a temp-file + rename pattern (=mktemp= + =mv= within the same filesystem) so receivers never see partial files. The implementation script (=cross-agent-send=) handles this. + +Receivers ignore =.tmp.*= files, processing only the final renamed name. + +*Two-file ordering.* When a message has a sibling GPG signature file (=.org.asc=), the writer MUST rename the =.asc= to its final name *before* renaming the =.org=. Two =mv= operations are not atomic together — without this ordering, a receiver could read the =.org= in the window between the two renames and fail GPG verify because the =.asc= hasn't landed yet. The rule: receiver only acts on =.org= files, and a =.org= without a corresponding =.asc= means the signature is genuinely missing (not still in flight). + +** Sequence numbering + +=#+SEQUENCE= is a *hint*, not a strict counter. Canonical order is =#+TIMESTAMP=. Sequences may collide under rapid back-and-forth (both sides write what they think is sequence N near-simultaneously). Treat collision as a normal protocol event. + +*Receiver-side dedup rule.* When a new file shares =CONVERSATION_ID= + =SEQUENCE= with an already-processed message, compare SHA-256 hashes. Identical hashes → silent dedup, treat as a retry. Different hashes → process both, ordered by =#+TIMESTAMP=. + +*Sender-side collision-reduction (best-effort).* Before picking sequence, scan the receiver's =from-agents/= for the highest existing sequence in this conversation across both sender prefixes. Use =max(seen) + 1=. + +** Message types + +- *request* — a side asks for work, input, or a decision. Sequence 1 is always =request=. +- *progress* — work-in-progress checkpoint. "Here's where I am, no action needed from you, more coming." Originator's poll loop should NOT page the user on progress messages. +- *query* — either side asks a clarifying question that blocks further work. Originator's poll loop SHOULD surface this immediately. Originator answers and work continues. +- *pushback* — receiver formally disagrees with the request and has *not* started the work. Carries reasoning. Distinct from =query= because the originator's response path differs. +- *complete* — receiver signals the requested work is done. Triggers verification. +- *release* — terminal type. Originator writes after verifying =complete=. Carries =RELEASE_STATUS= to disambiguate the closure mode. +- *escalate* — punts the conversation to the user for adjudication. Both sides pause polling on =escalate=; the user resolves. + +Reply expectation is implied by type: =request=, =query=, =pushback=, =escalate= expect a reply; =progress=, =complete=, =release= don't. + +** Conversation lifecycle + +A conversation is a directed loop between an originator (issued sequence 1) and a receiver: + +1. Originator writes =request= (sequence 1). Begins polling for replies. +2. *Optional acknowledgment.* Receiver may write a =progress= at sequence 2 to acknowledge receipt and set expectations. Required if work will take >5 minutes (so the originator's poll loop doesn't waste wakes). +3. *Optional echo-back.* For ambiguous or large requests, receiver writes a =progress= that restates work items and announces "starting now unless you push back within N minutes." +4. Receiver works. May write =progress= updates. =query= mid-work if blocked. =pushback= if the request is wrong. +5. Receiver writes =complete=. Begins polling for =release=. +6. Originator reads, *verifies the deliverable directly*. For subjective deliverables, verification is the originator's editorial accept. +7. If verified: =release= with =RELEASE_STATUS: complete=. If problems: new =request= (next sequence number). +8. Receiver sees =release=, stops polling. + +The verification step is load-bearing. =complete= is a *claim*; =release= is *verification*. + +** Pushback path + +On receiving a =pushback=, the originator chooses: + +1. *Revise* — new =request= with adjusted scope. +2. *Insist* — new =request= addressing the pushback's reasoning, standing by direction. +3. *Withdraw* — =release= with =RELEASE_STATUS: withdrawn-after-pushback=. + +*Deadlock cap.* After two pushback-insist exchanges, the next message MUST be =MESSAGE_TYPE: escalate=. Both agents pause polling; the user resolves. + +** =RELEASE_STATUS= values + +| Status | Meaning | +|---+---| +| =complete= | Goal achieved, originator verified | +| =cancelled= | Originator changed their mind mid-conversation | +| =withdrawn-after-pushback= | Originator chose option 3 on receiver's =pushback= | +| =abandoned-after-escalation= | User adjudicated and chose to close the conversation | +| =abandoned-after-timeout= | Receiver auto-closed after originator never returned to verify | + +** Async fallback + +If the originator session ends between =request= and =complete=, the receiver's =complete= goes unverified. Receiver behavior: + +- Polls for =release= up to ~24 hours of cycles (implementation default). +- After timeout, writes a final =progress= message ("treating as terminal-without-verification; originator never returned to release") and stops polling. Receiver does NOT write =release= itself — that would contradict the lifecycle rule that =release= is the originator's terminal action. +- Next time the originator project starts, the unreleased =complete= is surfaced as a startup item. The user can issue a late =release= (with whichever =RELEASE_STATUS= fits) or open a fresh conversation to revisit. =RELEASE_STATUS: abandoned-after-timeout= is used at that point if the user wants to formally close the orphaned thread. + +** Escalation + +A side writes =escalate= when: +- Pushback-insist deadlock cap reached. +- Conversation has stalled (no productive movement in N exchanges). +- A reply-expecting message has gone unanswered past timeout. + +Body summarizes both sides' positions in 60 seconds of reading. Both agents pause polling; the user resolves. + +* Implementation notes + +This sub-section describes how to operate the protocol. Operational detail lives in the seven scripts' READMEs. + +** Recommended scripts + +| Script | Replaces user action | README | +|---+---+---| +| =cross-agent-send <dest> <msg>= | Filename generation, GPG sign, atomic write, peer lookup, rsync push, retry+backoff, failure surfacing — seven mechanical sender-side steps. Frontmatter and message body are still author-supplied. | =cross-agent-send.md= | +| =cross-agent-recv <msg>= | Frontmatter sanity-check, =PROTOCOL_VERSION= verify, GPG verify, SHA-256 dedup, =REQUIRES_TOOLS= check — five mechanical receiver-side steps. Output is a structured decision (=process= / =dedup= / =query= / =reject=) the agent acts on. | =cross-agent-recv.md= | +| =cross-agent-watch= | Manually checking inboxes; "did I get a message?" | =cross-agent-watch.md= | +| =cross-agent-status= | Walking each project to count pending messages | =cross-agent-status.md= | +| =cross-agent-discover= | Remembering project topology and reachability | =cross-agent-discover.md= | +| =cross-agent-halt [reason] [--tailnet]= | Visiting each session to stop polling, restarting Claude Code, or hand-killing processes when comms go runaway. =--tailnet= propagates HALT to all peers. | =cross-agent-halt.md= | +| =cross-agent-resume [--tailnet]= | Manually clearing the HALT state and restarting the watcher. Per-session polling does NOT auto-resume — the user re-engages each session explicitly. | =cross-agent-resume.md= | + +The scripts are tools the user runs from any terminal. They do not depend on agent context — =cross-agent-status= run from a fresh shell works. + +A reader can comprehend this protocol from this spec alone. Script READMEs add operational detail that makes the protocol practical to use, but understanding the protocol's semantics requires only this document. + +** Polling + +Default cadence: 270 seconds (≈4.5 min). Sits just under the 5-minute prompt-cache TTL. + +If a side needs to slow down (heads-down work, idle wait), it writes a =progress= message saying so in prose. The other side adapts. There are no named polling modes. + +After ~12 empty polls in a row, the poll loop surfaces the silence to the user. + +A future runtime with native filesystem-event support could replace polling for active sessions; =cross-agent-watch= already provides event-driven notifications outside active sessions. + +** User multi-tasking + +- *Deferral.* If the user's last message in the agent's session was less than 60 seconds ago AND a poll fires, queue the inbox check until either the user sends another message OR 5 minutes pass without further input. +- *Surfacing.* On the next user-facing response: "While we were working on X, a cross-agent message landed from <project>. It's a =<type>= — want me to handle it now or after we finish?" +- *Mid-question.* Answer the user first. +- *Project switch.* If the user moves to the receiver project mid-conversation, the receiver agent surfaces the in-flight thread on first user prompt. +- *Conversation state.* Always include in any response that mentions a cross-agent thread: "<conv-id> at sequence N, awaiting <event>." + +** Failure modes + +The seven scripts surface most failures with concrete error messages. Spec-level failure modes: + +- *Malformed frontmatter on a received file.* Surface to user; do not act. +- *Mismatched =PROTOCOL_VERSION=.* Receiver writes =query= asking originator to upgrade. +- *Missing or invalid GPG signature.* Receiver surfaces "unsigned/unverified message"; refuses to act. +- *Sequence collision* with non-matching SHA-256. Process both, ordered by timestamp. +- *Required tool unavailable.* Receiver checks =REQUIRES_TOOLS= during frontmatter-sanity-check (before any work begins). On a missing tool, receiver writes =query= asking the originator to reframe the request to avoid the unavailable tool. Originator may revise (new =request=) or withdraw (=release= with =RELEASE_STATUS: cancelled=). =query= is the right type rather than =pushback= because missing-tool is a capability gap, not disagreement. +- *Runaway resource usage.* User invokes =cross-agent-halt= globally (or =cross-agent-halt --tailnet= for cross-machine). HALT file stops all components within one polling cycle (~5 min). See =* Halt mechanism= for the layered checks. +- *User halts mid-conversation.* Both sides write a final =progress= note ("HALT fired; pausing"); polling stops within one cadence; conversations resume on explicit per-session re-engage after HALT clears. +- *HALT file accidentally created* (typo, errant =touch=). =cross-agent-status= prominently flags HALT active; user clears with =cross-agent-resume=. Cost: no messages send during the typo window. +- *HALT file unreadable* (perms wrong, partial write). Each component fails-closed (treats as halted) and reports "HALT file present but unreadable; treat as halted." Safer than fail-open. + +Operational failures (rsync push fails, watcher dies, peer unreachable) live in the script READMEs' failure-mode tables. + +* Halt mechanism + +A failsafe to stop all cross-agent activity on a machine without visiting individual sessions or restarting Claude Code. Designed for the runaway-polling case: an agent has spun up conversations with N other agents, polling is eating CPU, and the user needs to stop everything *now*. + +** The HALT file + +Path: =~/.config/cross-agent-comms/HALT=. + +Existence triggers halt across all components on the machine. The file's body may carry an optional human-readable reason (reviewed by the user later when deciding to resume). + +User commands: + +#+begin_example +$ touch ~/.config/cross-agent-comms/HALT # halt +$ rm ~/.config/cross-agent-comms/HALT # resume +#+end_example + +Or via convenience scripts (=cross-agent-halt= / =cross-agent-resume=) that also handle the watcher service and cross-machine propagation. + +** Layered checks (the failsafe property) + +Every component MUST check the HALT file. The "any one component stops the system independently" property is what makes this failsafe — the system doesn't depend on a single point doing the right thing. + +| Component | Check timing | Behavior on HALT | +|---+---+---| +| =cross-agent-send= | At start of send + between =.asc= and =.org= rsync + between retry iterations | Refuse to start new send; complete current step then exit. Worst case: one in-flight send finishes within a few seconds. | +| =cross-agent-recv= | Before any verify or dedup | Leave inbound message in place — do NOT dedup, reject, or move. Resume picks it up via cold-start handling. | +| =cross-agent-watch= | At iteration start | Suppress notifications; log only. Continues running, no-op until HALT clears. | +| =cross-agent-status= | At start | Print prominent "⚠ HALT ACTIVE" banner before normal output. Read-only, continues. | +| =cross-agent-discover= | At start | Print HALT banner; continue read-only enumeration. | +| Agent polling loop | First action on every wake | Write a final =progress= note to any active conversation ("HALT fired; pausing"), do NOT reschedule, surface "halt active" to user. Polling decays within one cadence (~5 min). | +| Agent user-facing responses | Every response while HALT is set | Append "(HALT active; cross-agent comms paused)" to the response. On HALT clear, the next response says "(HALT cleared; cross-agent comms ready to resume — say so to re-engage polling)." Persistent, not just first-response — keeps awareness alive. | +| Conversation initiator | Before writing sequence 1 of any new conversation | Refuse and surface to user. | +| Startup workflow | Phase A on session start | If HALT exists, surface immediately and skip cross-agent inbox checks. | + +The agent polling-loop check is the load-bearing one for "stops eating CPU." Wake-ups already scheduled fire, but each wake on-HALT is a no-op + reschedule-prevention. Within one polling cadence (~5 min) all polling stops. + +*Fail-closed on unreadable HALT.* If the HALT file exists but is unreadable (wrong permissions, partial write), components MUST treat as halted. Safer than fail-open. + +** Resume asymmetry (deliberate) + +Halt is automatic everywhere. Resume requires explicit user intent per-session. + +When the user removes HALT (or runs =cross-agent-resume=), components stop refusing to act, but agent polling does NOT auto-resume. The user must open each session and tell that agent to resume polling for its conversations. + +The asymmetry exists because: + +1. Auto-resume could silently invert intentional kills. If the user halted because a session was misbehaving, removing HALT shouldn't quietly revive it. +2. Per-session resume forces the user to look at each session and confirm the situation is resolved before re-engaging. + +** Cross-machine halt + +=cross-agent-halt --tailnet= iterates =peers.toml= and SSH-touches HALT on each peer. Same shape for resume. + +Reports per-peer status with non-zero exit on partial halt: + +#+begin_example +$ cross-agent-halt --tailnet +Halting velox.local ✓ (HALT file written) +Halting bastion.local ✗ (ssh exit 255: no route to host) +Halting locally ✓ (HALT file written) + +PARTIAL HALT: 2/3 machines halted. bastion.local needs manual halt. +Exit 1. +#+end_example + +Scripting can detect partial halt via the exit code. Same pattern for =--tailnet= on resume. + +* Limitations + +- *Local-tailnet only.* Filesystem IPC + rsync over SSH. Cross-tailnet or cross-organization is out of scope. +- *Identity has three layers (Tailscale + POSIX + GPG)* but no message-content encryption. Confidentiality is not the goal; signing is correctness, not secrecy. +- *Single-receiver per conversation.* Fan-out to multiple receivers requires manually orchestrating multiple parallel conversations. +- *Polling is best-effort.* A wake may be delayed by an in-flight tool call until the runtime is idle. =cross-agent-watch= mitigates by offering event-driven notifications. +- *Project-extension drift.* If two projects' =.ai/project-workflows/= modify shared workflow definitions in incompatible ways, cross-agent assumptions can diverge silently. The optional =#+WORKFLOW_VERSION= advisory field is informational only in v5 — no implementation reads or acts on it. A future version may add enforcement on mismatch (e.g. receiver writes =query= asking which side is stale). Today, alignment is verified manually before high-stakes conversations. + +* Persistence after release + +Conversation files persist by default. The conversation log is the audit trail. + +Manual archival is fine if the inbox grows unmanageable. Suggested cadence: once the conversation has been =release='d AND the work it produced has shipped, archive both projects' message files into =.ai/sessions/cross-agent/= as a flat directory — no per-conversation subdirectories. Rename each archived file to lead with the conversation-id so messages from the same conversation cluster on =ls=: =<conv-id>-<TIMESTAMP>-from-<sender>.org= (and the matching =.asc= sibling, if present). Inbox filenames lead with the timestamp because chronological arrival is what matters in =from-agents/=; archives invert that because grouping by conversation is what matters when reading history. Keep the =.asc= signatures alongside the =.org= files in archive — they're small and document the GPG verification chain. + +Old messages don't affect protocol behavior (=cross-agent-status='s pending semantics correctly ignore released messages) but the =from-agents/= directory grows indefinitely without manual archival. =cross-agent-status= performance degrades noticeably when a project's =from-agents/= exceeds a few hundred files. =cross-agent-init= (deferred to v6) would include an archival sub-command. + +* Open questions + +- *=cross-agent-init= and =cross-agent-compose= helper scripts.* =-init= would be one-command project bootstrap (creates =inbox/from-agents/= with =chmod 700=, installs the =cross-agent-watch= systemd path unit, validates peer config, runs a discovery probe). =-compose= would be interactive frontmatter authoring (prompts for required fields, produces a draft message file). Both deferred to v6. Current onboarding requires manual =mkdir= + systemd setup per =cross-agent-watch.md='s install recipe; current message authoring requires writing the file by hand or via a small in-agent template. +- *Hard conversation timeout.* The async-fallback timeout is implementation-default ~24 hours. Right number depends on use case; tighten as patterns emerge. +- *=paused= polling state.* Today there's no clean signal for "pause without ending." Add when first user complaint surfaces. +- *Multi-LLM context.* If we ever bring in a non-Claude agent, the protocol's natural-language framing may need formalization. + +* Examples + +** =prep-fixup= conversation (2026-04-26 → 2026-04-27) + +Eleven exchanges between homelab and career produced the v4 spec by iterative critique-and-simplification. Three real-time sequence collisions during the conversation drove the sequence-as-hint rule that landed in v4 and persists in v5. + +Files at =~/projects/{homelab,career}/inbox/from-agents/= named =*-prep-fixup.org=. Worth re-reading when designing future cross-agent flows. + +** =comms-cold-start-discovery= conversation (2026-04-27) + +The follow-up that produced this v5 spec. Cold-start, watcher tooling, agent discovery, GPG identity, sha256 dedup, atomic writes, POSIX perms, script absorption, and process-vs-text simplification. Tonight's first cold-start in real time (career session went dormant after =prep-fixup= release; Craig's user-injection re-engaged it) is the worked demonstration of the v5 user-injection rule. + +Files at =~/projects/{homelab,career}/inbox/from-agents/= named =*-comms-cold-start-discovery.org=. diff --git a/claude-templates/.ai/workflows/daily-prep.org b/claude-templates/.ai/workflows/daily-prep.org new file mode 100644 index 0000000..319e94f --- /dev/null +++ b/claude-templates/.ai/workflows/daily-prep.org @@ -0,0 +1,801 @@ +#+TITLE: Daily Prep Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-23 + +* Overview + +This workflow prepares Craig for the next workday by reviewing scheduled meetings, identifying priorities, and blocking time on the calendar. It ensures Craig walks into every meeting prepared and has a plan for the day's focused work. + +This is typically run the evening before or morning of the workday in question. + +* Problem We're Solving + +Without daily prep, Craig risks: +- Walking into meetings without the right context loaded +- Missing opportunities to drive key decisions or raise important points +- Losing track of action items owed to or from specific people +- Not having focused work time protected on the calendar +- Reactive instead of proactive days + +*Impact:* Unprepared meetings waste everyone's time. Unplanned days let urgent displace important. + +* Exit Criteria + +- All meetings for the day are reviewed with prep notes +- 1:1 meetings have talking points drawn from todo.org and session history +- Day's priorities are identified and confirmed by Craig +- Time blocks are placed on the calendar for focused work +- Any quick tasks (< 5 min) are done during prep itself +- Craig feels ready for the day + +* When to Use This Workflow + +- When Craig says "let's prep for tomorrow" or "daily prep" or similar +- During evening wrap-up if there are meetings the next day +- Morning of a workday before meetings start +- When Craig asks for the standup brief alone ("what's my standup report", "let's do the daily standup report", "give me the standup brief") — see Standup-only mode below + +* Modes + +The workflow runs in one of two modes based on the trigger phrase. Pick the mode at workflow start. Don't switch mid-run. + +** Full-prep mode (default) + +Trigger: "let's prep for tomorrow", "daily prep", or any general prep-cycle phrase. + +Runs Phase A → Phases 1-5 → Phase 6 (only if a standup is on the calendar) → Phase 7 → Phase 8 → Phase 9. + +** Standup-only mode + +Trigger: "what's my standup report", "let's do the daily standup report", "give me the standup brief", or similar standup-specific phrasing. + +Runs *only* a slim Phase A + Phase 6 + a conditional Phase 8 + Phase 9. Skip Phases 1-5 and Phase 7 entirely. + +*** Slim Phase A (standup-only) + +Fetch only what Phase 6 needs: + +1. =todo.org= — for WAITING items (blockers) and DONE-since-cutoff (completed work). +2. Previous prep doc — anchor for the lookback (check =inbox/= then =daily-prep/=, take most recent). +3. Most recent =.ai/sessions/= summary — for the "what did I do" question. + +Skip the calendar fetches, the Proton grep, and the [#A]/[#B] todo pull. Phase 6 Step 1's sweep handles the rest. + +*** Where the brief lands + +- If a prep doc for today already exists at =inbox/YYYY-MM-DD-daily-prep.org=, append or replace its =* Standup Brief= section. +- If no prep doc for today exists, create =inbox/YYYY-MM-DD-daily-prep.org= with only the =* Standup Brief= section populated. Full prep can be added later by re-running in full-prep mode. + +*** Phase 8 in standup-only mode + +Only run if a stale prep doc exists in =inbox/= that's older than yesterday. Otherwise skip — there's nothing to archive. + +Use standup-only mode when Craig wants the brief fast (e.g., right before standup) without rebuilding the day's plan. + +* Prep Doc Structure + +The prep doc has a fixed set of top-level sections. Don't invent new ones. If substantive content surfaces that doesn't fit one of these headings, mention it in chat after the workflow finishes rather than adding it to the prep doc. + +Canonical sections (full-prep mode produces all; standup-only produces just =* Standup Briefs=): + +| Section | Phase that writes it | Purpose | +|----------------------------------+----------------------+----------------------------------------------------------------------------| +| =* Heads-up= | Phase 7 | Substantial situational context that changes Craig's frame for the day | +| =* Day's Priorities= | Phase 3 | Actionable items for today (with Email/Slack/Linear Response sub-sections) | +| =* Meetings / Work Blocks= | Phase 4 | Chronological schedule for the day (meetings + work blocks interleaved) | +| =* Standup Briefs= | Phase 6 | Yesterday / Today / Blockers content for the standup meeting | +| =* Upcoming Deadlines= | Phase 7 | Next 4-6 weeks of relevant deadlines, briefly | +| =* [Next day]'s Anchor Tasks= | Phase 7 | Explicit forward commitments for tomorrow. Phase 2 of next-day's prep reads this | + +Order in the prep doc follows the table top-to-bottom: Heads-up first (frame-setter), Day's Priorities next (action surface), then schedule, then standup, then deadlines, then forward-commitment. + +** Day's Priorities entries are thin links to todo.org tasks (2026-05-12 rule) + +Each actionable entry under =* Day's Priorities= is a *pointer to a todo.org task*, not a copy of its content. Form: + +#+begin_example +** TODO [#X] <task title> — [[file:../todo.org::*<task title>][todo.org]] +<one line on why it's today's priority — deadline, blocker, what just landed> +#+end_example + +All the substance — descriptions, drafted messages, research findings, sub-tasks, =VERIFY= asks, recommended-approach blocks — lives in the matching =todo.org= task, created or updated by Phase 3. If a todo.org task doesn't exist yet, create one (with the conventional priority cookie / Linear ID in the heading) and link to it. This keeps everything in one place (todo.org), so the prep doc never accretes content that then has to be transferred back. + +Exceptions that stay in the prep doc as content (not links): +- *Completed-today entries* — once a task is done, its heading becomes a dated log entry (=** YYYY-MM-DD Day @ HH:MM ...=, see [[file:../../.claude/...][feedback_done_tasks_become_dated_log_headings]] — i.e. the dated-heading rule) and stays in the prep doc as the day's record. If the work also has a durable todo.org home, the dated entry links to it. +- The =* Standup Briefs=, =* Heads-up=, =* Upcoming Deadlines=, and =* [Next day]'s Anchor Tasks= sections — those are prep-doc-native and don't map to todo.org tasks. + +(Craig's instruction, 2026-05-12. The Phase 3 sub-steps 3b–3f below still describe writing content into the prep doc's Response sub-sections — reword them to "create/update the todo.org task, link from the prep doc" on the next workflow pass.) + +* Approach: How We Work Together + +** Continuous flow (no mid-phase gates) + +Phases A through 7 run continuously. Don't stop between phases for Craig's input or confirmation. Information surfaced in later phases (especially Phase 3 email/Slack/Linear scans) frequently reframes what Craig wants from earlier phases — meeting goals, carry-forward decisions, priority order, time blocking. Stopping at Phase 1 to ask "what do you want from this meeting?" means asking Craig to react with stale context, before scans surface the email that might change his answer. + +Build all phases through Phase 7, then present the assembled draft and surface every question and proposed adjustment at the final review. + +Exception: stop only if a scan turns up something that *blocks* further building. Examples: a meeting was canceled and the whole day's structure changes; a security or compliance issue Craig must adjudicate before email triage proceeds. Otherwise keep going. + +This rule applies to all phases including Phase 1's "collaborative review" line and Phase 2's planned-vs-actual review. Both are framed as collaborative in their phase descriptions; the collaboration happens once, at the end, against the assembled prep doc. + +** Phase A: Data Gathering (one parallel batch) + +Before any synthesis or interaction, pull every source the prep doc needs in a *single batch of parallel tool calls*. These reads are all independent — issue them in one message, not as a sequential round-trip per source: + +1. =mcp__google-calendar__list-events= for the *personal* account, scoped to the day being prepped. +2. =mcp__google-calendar__list-events= for the *work* account, same scope. +3. Grep =~/.emacs.d/data/pcal.org= for items on that day. This is the Proton Calendar export (=pcal= = personal calendar). The same directory has =gcal.org= and =dcal.org= for Google personal + DeepSat, but those are already covered by the MCP queries in steps 1 and 2 — only =pcal.org= adds non-redundant items. +4. Read =todo.org= — collect [#A] and [#B] tasks plus anything with DEADLINE: or SCHEDULED: touching the day. +5. List + read the *previous* prep doc. Check =inbox/= first (active prep docs not yet archived), then =daily-prep/= (the archive). Take the most recent file by date — usually yesterday's, but may be older if Craig was off (PTO, weekend, missed days). The lookback for the standup brief is anchored on this file's date, so accuracy matters. +6. Read the most recent =.ai/sessions/= summary file (for the standup brief's "What did I do since last standup?" question). + +Phases 1-5 below all work from this in-memory snapshot. *Do NOT re-query the calendars in Phase 4* — the time-blocking pass uses the same data Phase A fetched. A typical daily-prep used to run 4-5 sequential round-trips just for data gathering; this collapses to one. + +** Phase 1: Meeting Review + +Working from the Phase A calendar snapshot, present each meeting with: + +1. *Time and duration* +2. *Meeting name* +3. *Owner* (who scheduled/owns the meeting) +4. *Official agenda* (from calendar description) +5. *What Craig needs from this meeting* -- decisions to drive, points to make, information to convey, people to persuade + +For the last item, Claude may not know Craig's goals for the meeting. Present what's known and Craig will clarify during review. + +*** 1:1 Meeting Prep + +1:1 meetings get deeper preparation: + +1. Check todo.org for tasks tagged with or related to the person +2. Review session histories since the last 1:1 with that person for relevant events, decisions, and context + - Default lookback: since the last 1:1 with that person + - Craig may ask to go further back for long-running items +3. Identify: + - Status updates Craig should share (progress on things they care about) + - Questions Craig needs to ask + - Decisions that need their input + - Action items owed to or from them + +Capture the meeting list with what's known about each (time, owner, official agenda, who's accepted vs declined, what Craig might need). Don't stop here for Craig's input. Bring questions about meeting goals to the final review at the end of Phase 7, when the cross-source picture from Phase 3 is in hand. The collaborative review of the meeting list happens once, against the assembled prep doc, not as a mid-flow gate. + +** Phase 2: Planned vs. Actual Review + +Before setting tomorrow's priorities, review what was planned for today against what actually happened. This catches work that slipped and prevents it from silently disappearing. + +1. Pull the current day's prep doc (if one exists). If it has a populated =* [Today]'s Anchor Tasks= section (written by yesterday's Phase 7 as the explicit handoff), use that as the canonical carry-forward list. Otherwise fall back to inferring carry-forward by listing the day's planned work blocks. +2. For each block, note: completed, partially done, or not started +3. For items that didn't happen, identify why (took longer than expected, blocked, deprioritized, meetings ran over) +4. Carry forward anything that still matters into the priority list for the next day, with updated time estimates based on what we learned today +5. Add all carried-forward items as =TODO= entries in the new prep doc's Day's Priorities section — don't just note them in the Planned vs Actual table. Unfinished tasks from yesterday become today's tasks automatically unless Craig cuts them. + +This step keeps the daily prep honest. If a 1-hour task consistently takes 3 hours, the estimates need to change. If work keeps getting bumped by meetings, that's a pattern worth raising. + +** Phase 3: Day's Priorities + +Assemble priorities automatically from multiple sources. No interactive confirmation. Craig reviews the prep doc as a whole when it's done. + +Write the assembled list as =TODO= entries under the prep doc's =* Day's Priorities= section. Order by urgency — most time-sensitive or blocking first. If Craig wants to add or remove a priority, he edits the prep doc directly. + +Sources contributing to Day's Priorities: todo.org, email, Slack, Linear. + +*Don't create empty response sub-section headers.* Only emit =** Email Response=, =** Slack Response=, or =** Linear Response= when there's at least one item to put under it. The =# Sources checked:= footer at the top of the prep doc shows which sources were scanned (✓ = ran, ✗ = skipped) — Craig already knows from the footer that Slack and Linear were checked even when those subsections aren't in the doc. Empty headers add visual noise and imply something was missed. + +*Linear digest / notification emails don't get their own TODO.* If sub-step 3b returns a Linear digest email saying "you have N unread notifications," that's not an Action item — Linear notifications surface either via 3e (the direct Linear query) or via the underlying per-notification emails Linear sends. Classify the digest as Noise-keep (or Noise-trash if the per-notification emails are also flowing through) and don't add a separate "Linear notifications check" entry to Day's Priorities. + +*** Recommended Approach Pattern (used by sub-steps 3b, 3d, 3e) + +When an item involves a non-trivial decision or judgment that benefits from analysis, include a =recommended approach= subheader before the response draft. The approach is the executor's analysis — situation, options, recommendation with rationale, considerations Craig should weigh — so Craig can evaluate the recommendation itself, not just the wording. + +Skip the approach subheader for routine acknowledgments, simple status replies, or "yep, looks good" approvals. + +Source-specific examples of when to *include* the approach subheader: + +- *Email:* decisions like grant applications, partnership replies, public-facing or sensitive responses +- *Slack:* @mentions with explicit asks, items where someone is blocked waiting on Craig +- *Linear:* @mentions requesting input, Blocked tickets Craig owns, Needs-Review items where the call isn't trivially obvious + +Format (consistent across all three sources): + +#+begin_example +***** YYYY-MM-DD Day @ HH:MM:SS -ZZZZ recommended approach +<situation, options, recommendation with rationale, considerations> +***** YYYY-MM-DD Day @ HH:MM:SS -ZZZZ recommended response +<draft response text, OR proposed action like a state change> +#+end_example + +Generate timestamps with =date "+%Y-%m-%d %a @ %H:%M:%S %z"= so they're accurate, not estimated. + +*** Sub-step 3a: From todo.org + +1. Pull all [#A] tasks from todo.org. +2. Add time-sensitive items (DEADLINE: or SCHEDULED: touching the day). +3. Carry forward unfinished items surfaced by Phase 2 — but skip any item already added by step 1 or 2 above. A task that was [#A] yesterday and didn't finish gets carried forward by Phase 2 and would also re-appear in step 1's [#A] pull. Dedupe by org-mode heading text or =:ID:= property if present. + +Priorities typically include: +- Messages to send (Slack, email) +- Meetings to schedule +- Documents to send out for review +- Follow-ups to make +- Prep work needed before a meeting + +*** Sub-step 3b: From email + +Scan email since the prior prep doc's *mtime* (when the file was last written, not the date in its filename or title) — both accounts, unread or unanswered, with the =summarize-emails=-style noise filter (=NOT flag:list=, addressed-to-Craig). Express the cutoff to Gmail as =after:YYYY/MM/DD HH:MM:SS= so messages dated *between the prior prep's write time and now* land in scope. Anchoring on filename date or Gmail's day-granular =after:= operator drifts when a prep doc is generated late at night for the next day — the wrong window catches a full extra calendar day or misses several hours. + +*Track every message path returned by the scan* (Action, FYI, and Noise alike). Sub-step 3c uses this list to mark-read only the emails actually processed, avoiding a race condition with emails arriving mid-workflow. + +For each email, classify: + +- *Action* — explicit ask, deadline, request for decision, or Craig is the bottleneck +- *FYI* — informational, no action required (note in session-context.org if substantive, otherwise drop) +- *Noise-keep* — automated/CC-only with no expected response, but has residual value (mark read; keep in inbox archive) +- *Noise-trash* — automated/CC-only with no expected response AND no residual value (trash it; see criterion below) + +*Trash criterion.* Trash if both are true: + +1. No transactional / financial / security record value (would I ever search for this in 6 months?) +2. Not direct human-to-human correspondence + +Otherwise mark-read but keep. + +*Per-account bias.* DeepSat work account biases toward keeping — defense engineering audit-trail value, storage is free. Personal Gmail biases toward trashing — high volume, low residual value, search clutter. + +Common *Noise-trash* patterns: +- Newsletter / blog digest content (Substack subscriptions you don't actually read, daily/weekly roundups) +- Retail / SaaS / EDC marketing promos +- Social-network engagement bait (LinkedIn social digests, Instagram followers, Bandcamp DMs) +- Aggregate notification digests redundant with their source app (Linear digest emails, Notion/Miro daily/weekly engagement pings) +- Wrong-recipient mail (mailing-list typo addressed to a different person) +- Past-event calendar artifacts (cancellations/updates for events that already happened) +- Stock-tip teasers, deal alerts, review-request prompts (Hotels.com / Airbnb "rate your stay") + +Common *Noise-keep* patterns: +- Financial: dividend statements, monthly statements, payment confirmations, invoices (paid or unpaid) +- Account security: new-login alerts, new payment method added, password reset, OAuth grants +- Domain / registrar admin (renewal notices, WHOIS contact reminders) +- Direct human correspondence (even if FYI, even if you didn't reply) +- Booking confirmations for trips actually being taken +- PR / code review notifications (audit-trail value on the work account) +- CI / deploy failure alerts (incident archive) + +*Email link format.* Every email surfaced in the prep doc must link directly to the actual message in the right Gmail account, not just to a generic Gmail tab. Construct the URL as: + +#+begin_example +https://mail.google.com/mail/u/<account-index>/#all/<thread-id> +#+end_example + +Two pieces matter: + +- *Account index.* The =u/<n>= segment selects which Gmail account opens the link. Indexes are stable per Craig's setup: + - =u/0= → personal account (=craigmartinjennings@gmail.com=, served by the =google-docs= MCP) + - =u/1= → DeepSat work account (=craig.jennings@deepsat.com=, served by the =google-docs-work= MCP) + Pick the index that matches the source MCP. A work email linked with =u/0= opens personal Gmail and shows nothing - that's the failure mode this rule prevents. +- *Thread ID.* Use the =threadId= field from =listMessages= or =getMessage=, not the =messageId=. Gmail's web URL routes by thread. Use the =#all/= view (works whether the message is still in the inbox or has been archived) - don't use =#inbox/=, since the link breaks the moment the message moves out of inbox. + +If a project routes mail through a different account layout (other tenants, additional accounts), record the index mapping in that project's =.ai/notes.org= and override the defaults above. + +For each Action item: + +1. *Star the email in Gmail* so it's flagged in Craig's inbox outside the prep doc: + #+begin_src bash + python3 .ai/scripts/maildir-flag-manager.py star --reindex /path/to/message.eml + #+end_src +2. *Read* the email content (use =eml-view-and-extract-attachments.py= in stdout mode if needed). +3. *Capture substantive content* in the right place based on what kind of information it is: + - =deepsat/knowledge.org= (or the project's equivalent) — persistent facts the project should remember: new contacts to add to the roster, system or infrastructure details, strategic decisions, transcription corrections, vendor relationships. + - =.ai/session-context.org= — today's session context: what was read, decisions made or pending, follow-ups identified for later in the session. + When in doubt, default to session-context. The next session can promote anything worth keeping into knowledge.org during wrap-up. +4. *People-Context Check (runs before the recommended response is drafted).* When the Action item involves a specific person (sender, recipient, or a named third party referenced in the body), look that person up in =deepsat/knowledge.org= (Key People table, plus the Team Details section if present). Pull role and reporting line, relationships to other key people (family, prior employer, advisor vs. employee), tone and working-style signals, and recent context that affects how Craig should phrase the response. If the person isn't in =knowledge.org=, capture what's known there before writing the draft — the people layer is persistent context, every future prep gets faster when this layer is complete. Skip the check for purely transactional senders (Mercury, GitHub notifications, Linear bots). The recommended-approach + recommended-response then incorporates the people-context: tone, channel choice (email vs Slack vs in-person), and framing should reflect the relationship. +5. *Add to the prep doc* under Day's Priorities as an =Email Response= sub-section: + #+begin_example + ** Email Response + *** [[mail-link][From X: Subject]] + - One-line description of why action is needed. + - Suggested action: reply with status / approve / decline / schedule. + <recommended approach + recommended response per the Recommended Approach Pattern above> + #+end_example + +For each FYI item: read it, capture substantive content into knowledge.org or session-context per the routing above, then drop it from further processing. Don't add to the prep doc. + +For each Noise-keep item: no per-message action beyond the read-state pass at sub-step 3c. Don't add to the prep doc. + +For each Noise-trash item: trash it. Trashing removes the message from INBOX and parks it in Trash for 30 days before Gmail's auto-purge, so no separate mark-read is needed at sub-step 3c. + +#+begin_src bash +# MCP path (preferred — works directly against Gmail): +mcp__google-docs__trashMessage --messageId <id> # personal account +mcp__google-docs-work__trashMessage --messageId <id> # DeepSat account + +# maildir path (fallback for offline / mu4e workflow): +python3 .ai/scripts/maildir-flag-manager.py trash --reindex /path/to/message.eml +#+end_src + +Don't duplicate Email Response items as standalone Day's Priorities entries — the =Email Response= sub-section IS the priority surface for those items. + +*** Sub-step 3c: Mark processed email as read + +After 3b is fully complete, mark *only the message paths 3b processed* (Action, FYI, Noise-keep) as read — not all unread INBOX emails. Noise-trash items are already handled at 3b and don't need a read-state pass. + +#+begin_src bash +# MCP path (preferred): +mcp__google-docs__modifyMessageLabels --messageId <id> --removeLabelIds '["UNREAD"]' + +# maildir path (fallback): +python3 .ai/scripts/maildir-flag-manager.py mark-read --reindex /path/to/msg1 /path/to/msg2 ... +#+end_src + +Scoped mark-read avoids a race where an email arriving between 3b's scan and 3c's mark-read would be silently marked read without being processed. + +Stars persist (separate flag), so Action items remain findable in Gmail by their star, not by unread state. + +*Before exiting Phase 3, verify the triage actions actually executed:* every Action item has a Gmail star AND is marked read; every FYI and Noise-keep item is marked read; every Noise-trash item is in Trash. The Phase 3 audit footer at sub-step 3g is the forcing function for source coverage; this verification is the parallel for triage execution. Producing only the audit footer without running the API calls leaves Craig with an unread inbox even though every message was *seen* in the prep doc — defeats the inbox-zero purpose of the workflow. Count what was processed (e.g., "44 trashed, 5 noise-keep marked-read, 19 starred + marked-read, 30 FYI marked-read") and confirm the totals match the classification. + +*** Sub-step 3d: From Slack + +Query Slack since the prior prep doc's *mtime* (when the file was last written, not its filename date). Three streams (filters out general channel chatter Craig wasn't directly addressed in): + +1. *DMs Craig hasn't replied to.* Call =mcp__slack-deepsat__conversations_unreads= with =channel_types='dm'= and =max_channels=100=. Each row is a DM message Craig hasn't read. +2. *Channel @mentions of Craig.* Call =mcp__slack-deepsat__conversations_unreads= with =mentions_only=true= and =max_channels=100=. This is the *only reliable* way to find @mentions in this MCP — see the search-doesn't-work-for-mentions note below. Limitation: this only catches *unread* @mentions; ones Craig already saw are out of scope, which is the right trade-off (already-read mentions were seen and either actioned or consciously skipped). +3. *Thread replies in threads Craig started or last commented in.* No direct API for "threads I started." Practical approach: in the unreads pulled by streams 1 and 2, look at the =ThreadTs= column — non-empty values mean the message is a thread reply. Use =mcp__slack-deepsat__conversations_history= on the parent channel to fetch the full thread context if any reply looks substantive. + +*Slack search caveat (don't waste time here).* =mcp__slack-deepsat__conversations_search_messages= does *not* index =<@USERID>= tokens as searchable text. Searches for =<@U0A8AJTEM9V>=, =@craig.jennings=, or even Craig's plain display name return empty even when @mentions exist. Slack's own search has the same limitation — the "Mentions & Reactions" panel in the Slack app uses the unreads-with-mentions API, not search. Don't fall back to =conversations_search_messages= to find @mentions; use =conversations_unreads(mentions_only=true)= as above. The search tool is fine for keyword searches in message text (e.g. "find any message mentioning 'STRATFI'"). + +For each result, classify: + +- *Action* — explicit ask, decision needed, or Craig is the bottleneck +- *FYI* — informational, no action required (capture in knowledge.org or session-context if substantive, otherwise drop) +- *Noise* — bot pings, automated alerts, off-topic mentions (drop silently) + +For each Action item: + +1. *Read* the message and surrounding thread context. +2. *Capture substantive content* in =deepsat/knowledge.org= (or the project's equivalent) for persistent project facts, or =.ai/session-context.org= for today's context. Same routing rules as Sub-step 3b — default to session-context when unsure. +3. *People-Context Check* — same as sub-step 3b's step 4. When the message involves a specific person, look them up in =knowledge.org= before drafting. Skip for transactional bot pings. +4. *Add to the prep doc* under Day's Priorities as a =Slack Response= sub-section: + #+begin_example + ** Slack Response + *** [[slack-permalink][From X in #channel: brief description]] + - One-line description of why action is needed. + - Suggested action: reply / react / move to thread / schedule. + <recommended approach + recommended response per the Recommended Approach Pattern above> + #+end_example + +For each FYI item: read it, capture substantive content per the routing above, then drop. No prep-doc entry. + +After processing all items, mark *only the specific DMs and @mentions we touched* as read in Slack — not channel-wide. The Slack MCP should expose a per-message mark-read capability; if the available MCP doesn't support it, skip this step and let Craig manage Slack read state himself. Do NOT mark channel chatter as read; only the items the query returned. + +Don't duplicate Slack Response items as standalone Day's Priorities entries — the =Slack Response= sub-section IS the priority surface for those items. + +*** Sub-step 3e: From Linear + +Query Linear since the prior prep doc's *mtime* (when the file was last written, not its filename date), using =updatedAt= as the timestamp anchor. Three streams: + +1. Tickets *assigned to Craig* with state changes or new activity. +2. Tickets *created by Craig* with state changes or new comments (someone else moved or replied). +3. Tickets where Craig is *@mentioned* in a recent comment. + +Use Linear MCP =list_issues= with the appropriate filters; =get_issue= and =list_comments= for individual reads. + +For each result, classify: + +- *Action* — ticket in "Needs Review" assigned to Craig; @mention requesting input; new comment with a question; deadline within ~48h; ticket assigned to Craig in a "blocked-on-me" state +- *FYI* — state change on Craig's ticket by someone else; comment that's a status update; related-ticket activity worth knowing +- *Noise* — bot updates, automated transitions (drop silently) + +For each Action item: + +1. *Read the ticket* — description, recent comments, state history. +2. *Capture substantive content* in =deepsat/knowledge.org= (people roles, system facts, strategic decisions) or =.ai/session-context.org= (today's review notes). Same routing rules as Sub-step 3b — default to session-context when unsure. +3. *People-Context Check* — same as sub-step 3b's step 4. When the ticket involves a specific person (assignee, reviewer, commenter, @mentioned), look them up in =knowledge.org= before drafting. Skip for bot updates and automated transitions. +4. *Add to the prep doc* under Day's Priorities as a =Linear Response= sub-section: + #+begin_example + ** Linear Response + *** [[https://linear.app/...][SE-NNN]] Ticket title (state, assignee) + - Why action needed: one-liner (e.g., "Vrezh moved to Needs Review, awaiting Craig"). + - Suggested action: review code / post comment / change state to X. + <recommended approach + recommended response per the Recommended Approach Pattern above> + #+end_example + + Linear's =recommended response= can be a draft comment, a proposed state change with rationale, or a combined action like "comment + reassign to Vrezh" — the response is "what Craig should do," not just reply text. + + *When the action item is a comment or @mention,* don't write "read X's comment." Fetch the comment via =mcp__linear__list_comments= and paste it inline as a child header so Craig can answer in the prep doc: + #+begin_example + *** [[https://linear.app/...][SE-NNN]] Ticket title — <Author> @mentioned Craig in a comment + - Why action needed: direct @mention; ties to <related ticket if any>. + - Suggested action: answer inline below; I'll post the reply on the ticket. + **** <Author> — <YYYY-MM-DD HH:MM> — comment on SE-NNN + #+begin_quote + <the comment text, verbatim> + #+end_quote + ***** Craig's reply (fill in / approve, then I post it to the ticket) + <placeholder> + #+end_example + Same idea for an FYI comment Craig should see — paste it, don't just reference it. (Feedback memory: =feedback_linear_comment_as_child_header=.) + +For each FYI item: read it, capture substantive content per the routing above, then drop. No prep-doc entry. + +*Two deliberate divergences from email/Slack:* + +1. *No mark-as-read step.* Linear has no "unread" concept. The processed signal is implicit — once Craig acts on the ticket (comments, changes state), it stops appearing in the next query. Items he doesn't act on re-surface tomorrow, which is correct. +2. *No "star" or "save" equivalent.* Linear ticket URLs in the prep doc are enough. + +*Volume note:* if a query returns >20 tickets, surface the count and prioritize Action signals first (Needs Review assigned to Craig, @mentions, blocked tickets, deadlines). + +Don't duplicate Linear Response items as standalone Day's Priorities entries — the =Linear Response= sub-section IS the priority surface for those items. + +*** Sub-step 3f: From Open PRs + +Scan open pull requests on the project's primary repo (per-project; for DeepSat the canonical repo is `~/projects/work/deepsat/code/orchestration_dashboard_mvp`). Use `gh pr list` to enumerate, classify each, and surface Action items in the prep doc. + +Scan command (single round-trip): + +#+begin_src bash +gh pr list --repo <owner>/<repo> --state open --json number,title,author,reviewRequests,isDraft,updatedAt,headRefName,additions,deletions,url +#+end_src + +For each result, classify: + +- *Action* — Craig is in `reviewRequests`, OR Craig was a reviewer and the author force-pushed since Craig's last review (re-review needed), OR the PR is blocked on Craig's response in a thread. +- *FYI* — open PRs Craig isn't reviewing but worth noting (a teammate's branch in flight, draft PRs, Craig's own PRs awaiting others). +- *Noise* — bot PRs (dependabot, renovate, etc.). Drop silently. + +For each Action item: + +1. *Read the PR* — diff summary, recent commits, review state, any unresolved threads. +2. *People-Context Check* — same as sub-step 3b's step 4 for the PR author and the requesting reviewer. +3. *Add to the prep doc* under Day's Priorities as a `** PR Review` sub-section: + +#+begin_example +** PR Review +*** [[PR-URL][PR #N — title (author, branch)]] +- Why action needed: review requested / re-review after force-push / blocked on Craig's response. +- Suggested action: full review / quick re-review / unblock thread. +<recommended approach + recommended response per the Recommended Approach Pattern above> +#+end_example + +For FYI items: include a short *Craig's own PRs awaiting review (FYI, not action)* sub-list under the PR Review section so the queue stays visible without inflating the action surface. One line per PR: number, short title, who's been requested. + +*Two deliberate divergences from email/Slack:* + +1. *No mark-as-read step.* PR review state is implicit — once Craig submits a review or a comment, the PR's review-status changes and re-classification happens on the next prep. +2. *No "star" equivalent.* PR URLs in the prep doc are enough; Craig's review queue is already filterable in the GitHub UI. + +*Per-project repo configuration.* The repo path is project-specific. For projects without a primary repo (personal documentation projects, etc.), skip this sub-step and mark `prs ✗ (no primary repo)` in the audit footer. For projects with multiple repos in active development, scan each in turn; surface Action items with the repo name prefixed in the title. + +Don't duplicate PR Review items as standalone Day's Priorities entries — the `PR Review` sub-section IS the priority surface for those items. + +*** Sub-step 3g: Cross-source dedup and urgency re-sort + +After 3a-3f have written all their entries to Day's Priorities, do a final cleanup pass. + +*Cross-source dedup.* Scan the Email Response, Slack Response, Linear Response, and PR Review sub-sections for items that reference the same conversation or topic. Common cases: + +- Vrezh DMs Craig about a Linear ticket *and* comments on the ticket itself — both surface as separate drafts +- An email points at "let's discuss on the ticket" — the ticket also has activity +- A Slack thread is the followup to an email exchange + +For each candidate duplicate pair, surface to Craig: + +#+begin_quote +These two items look like the same conversation: [Email link] and [Linear link]. Should I collapse them under one source, or keep both? +#+end_quote + +Wait for Craig's call. If he says collapse, keep the source he picks and add a cross-reference link in the kept item ("see also: [other-link]"). Don't auto-collapse. + +*Urgency re-sort.* Items currently appear in sub-step order (todo.org, then Email Response, then Slack Response, then Linear Response). Re-order all top-level entries under =* Day's Priorities= by urgency: + +1. Items with deadlines today or already overdue +2. Items where Craig is blocking someone (Slack blocked-on-Craig, Linear Blocked tickets, Needs-Review assigned to Craig) +3. Items with deadlines within ~48 hours +4. Other [#A] tasks +5. Time-sensitive but lower-stakes items +6. Everything else + +Within each tier, preserve the source-section structure (Email Response, Slack Response, Linear Response sub-sections stay grouped) — just re-order the top-level entries that aren't already sub-sectioned. + +Craig can re-order further when he reviews the prep doc; this just gives him a sane starting order. + +*** Sub-step 3h: Phase 3 audit footer (forcing function) + +After 3a-3g are done, write a single comment line at the top of the prep doc — directly below the =#+DATE:= header — recording which sources actually got checked: + +#+begin_example +# Sources checked: todo.org ✓ | email-personal ✓ | email-deepsat ✓ | Slack ✓ | Linear ✓ | prs ✓ +#+end_example + +Replace the ✓ with ✗ for any source that was skipped, and add a parenthetical reason after each ✗ (e.g. =Slack ✗ (MCP disconnected)= or =email-personal ✗ (auth scope error)=). If a source was scanned but returned no items, keep the ✓ — empty is a valid scan result; "didn't run at all" is what this line catches. + +This footer is the canary that surfaces silent skips. The reason it's a forcing function: writing the line means deciding what mark each source gets, which means actually checking that each source ran. A skipped sub-step now requires an explicit ✗, not a silent omission of a section. + +** Phase 4: Time Blocking + +Once priorities are confirmed, block time on the calendar to accomplish them. Phase 4 also writes the prep doc's =* Meetings / Work Blocks= section — a single chronological list interleaving meetings with focused-work blocks, top to bottom in time order. + +*** Quick Tasks (< 5 minutes) +Tasks like "schedule a meeting with Ryan" or "send a Slack message" should be done during the prep workflow itself, not scheduled separately. Draft the message or create the calendar event on the spot. + +*** Focused Work Tasks +For anything requiring more than a few minutes: + +1. Ask Craig for a time estimate on each item +2. Use the Phase A calendar snapshot — *do not re-query the calendars*. The same events are already in context. +3. *Compute the day's active window.* Default 10:00 to 17:00, every day of the week (weekends included). If the prep is being run *same-day* (the prep doc's date matches today's date) and the workflow start-time is later than 10:00, the window starts at the start-time instead. Existing calendar appointments, known personal commitments (guests arriving, travel, off-time blocks Craig flagged), and the last-30-min next-day-prep reservation remove time from the window. Whatever's left is fillable with priorities. Don't auto-treat Saturday or Sunday as "off." Fill the window with tasks. If Craig wants personal time on a weekend, he marks it on the calendar or flags it during prep. +4. List the schedule with open slots clearly marked. Show the active window's start, end, and remaining fillable minutes after subtracting appointments. +5. Propose when each task fits, considering: + - Meeting proximity (prep work should be near the meeting it supports) + - Energy/focus (harder tasks earlier if possible) + - A half hour around lunchtime to eat when possible (protein shake or leftovers is fine -- this can be compressed or skipped on heavy days) + - Always reserve the last 30 minutes of the workday for daily prep for the following day. This is non-negotiable -- it's what keeps the cycle going. +6. Craig confirms or adjusts the proposed schedule + +*** Placing Calendar Blocks +- Place time blocks on Craig's personal Google calendar (=Craig Google=) +- Can also place blocks on DeepSat calendar directly using MCP server with =account_id: "work"= +- Use the calendar event workflows (add-calendar-event.org) for creating events + +** Phase 5: Prep Work + +With the schedule set, work on anything that needs to be ready for the day's meetings. Examples: + +- Watch tutorial videos and take notes +- Draft or polish documents for review +- Prepare talking points or materials +- Research topics that will come up in meetings +- Draft messages that are part of the day's priorities + +This phase is where the bulk of the session time goes. Work through items in priority order, with meeting-related prep taking precedence based on meeting time. + +** Phase 6: Standup Brief + +Generate the standup brief late in the workflow so the rest of the day's analysis is already done. The brief draws from Phase 2 (Planned vs Actual), Phase 3 (Day's Priorities), and the activity sweep below. Phase 6 writes the prep doc's =* Standup Briefs= section. + +Skip this phase entirely on days with no standup on the calendar. + +*** Step 1: Sweep recent activity for off-Claude signals + +Before drafting, check these sources for activity since the previous prep doc's date (the lookback anchor from Phase A step 5): + +1. *Sent email* — both accounts. Outgoing threads from Craig: decisions communicated, replies to action items, follow-ups, intros made. +2. *Slack* — Craig's recent messages across channels and DMs. Decisions, status updates, threads where Craig participated. +3. *todo.org* — items marked DONE since the cutoff. New TODOs added that hint at fresh context (new commitments, new blockers). +4. *Linear* — tickets Craig moved to a new state, commented on, or created. Use the Linear MCP to query. + +These surface team-visible work that lives outside session history (work done in mu4e, Slack, the Linear UI, or off-Claude conversations). + +*** Step 2: Draft the brief + +Combine session history + the Step 1 sweep + Phase 3 priorities + todo.org WAITING items into the three-question structure below. + +*Meeting selection.* Recurring meetings are excluded from both the Yesterday and Today sections — they're not news to the team. For non-recurring meetings: + +- *Yesterday section:* include only meetings Craig actually attended. Filter on Craig's own response status from the calendar event: + - =accepted= → include + - =tentative= or =declined= → exclude (don't assume attendance) +- *Today section:* include all non-recurring meetings regardless of response status. Craig still needs to communicate what he plans to attend or decide between. + +The Google Calendar MCP returns =responseStatus= per attendee — filter on Craig's own response, not the event's overall status. Recurring events expose =recurringEventId= or =recurrence=; treat the presence of either as the recurring marker. + +*Content rules.* + +- *Stay first-person.* Report what Craig did, said, or decided. Don't volunteer others for work or report what others said they would do — that's their standup, not Craig's. If a teammate's commitment is relevant context, frame it as something Craig is waiting on, not as a status update on their behalf. +- *Match deadline precision to what's actually known.* If a deadline is "early next week," don't tighten it to "Monday." If the commitment is "before the proposal goes out," don't sharpen it to "Friday EOD." Vague is honest when vague is the truth. + +1. *What did I do since last standup?* Anchor the lookback at the previous prep doc's date (Phase A step 5). Pull session history, session-context.org, completed tasks, and the Step 1 sweep results from that date forward. Since DeepSat standup is a workday-recurring meeting, the previous prep doc's date is the previous standup date even if intervening days were weekends or PTO. Use explicit day references ("Monday" not "yesterday") since the prep doc may be written the evening before. +2. *What am I doing today?* Pull from the day's priorities (Phase 3 output). Keep it to 2-3 items max. +3. *Blockers: mine or yours?* The bar is "did this actually stop me from making progress?", not "is someone else involved?" A WAITING item in todo.org is only a blocker if Craig already tried to move forward and got stopped. Mere dependencies don't qualify unless they've already impaired progress. Default to under-reporting — draft without borderline items and let Craig add them back in Step 3. FYI items (decisions or context worth flagging that aren't blockers) come after blockers and stay loose. + +The brief should be concise enough to read aloud in under 60 seconds. Include enough context that Craig doesn't have to think on his feet, but not so much that it turns into a status report. + +*** Step 3: Present the draft and ask what's missing + +Show Craig the full draft brief — Yesterday, Today, Blockers/FYI sections all populated. Then ask: + +#+begin_quote +Anything I missed? Common categories: off-Claude meetings or phone calls, paperwork or forms submitted, intros made or received, vendor conversations, anything else not captured in session/email/Slack/Linear. +#+end_quote + +Recognition is faster than recall. Craig can react to "did you make any intros?" much faster than to "what did you do?" The categories are prompts, not a checklist. + +*** Step 4: Refine and finalize + +Apply Craig's additions and any wording adjustments. The brief is now ready for standup. + +*** Step 5: Offer to capture learnings + +After Step 4, scan Craig's refinements for non-obvious patterns: + +- Did he cut a category of item the workflow said to include? +- Did he add a category the workflow didn't tell you to look for? +- Did he change wording in a way that suggests a phrasing rule (e.g., "say 'continued focus on X' instead of 'worked on X'")? + +If a refinement looks like a generalizable rule, offer it back to Craig: + +#+begin_quote +I noticed you [removed all 1:1 meetings from Yesterday / added the off-Claude phone call with Ryan / changed "completed proposal draft" to "continued focus on proposal"]. Want me to add this to the workflow? Proposed rule: "[concrete rule text]" +#+end_quote + +Wait for explicit confirmation before editing the workflow. Never edit on your own judgment. + +If Craig confirms, append the rule to the *Updates and Learnings* section at the bottom of this file with today's date and a one-line description. If the new rule supersedes existing text inside Phase 6, also update the inline text and note "(updated YYYY-MM-DD — see Updates and Learnings)" in the affected section so the audit trail is complete. + +If the refinement looks like a one-off, don't propose a rule. Default to under-proposing — false-positive rules clutter the workflow. The bar is "would I want this guidance the next time I draft a brief?" If yes, propose. If no, drop it. + +** Phase 7: Final Section Writeups + +Three sections remain after Phase 6: =* Heads-up=, =* Upcoming Deadlines=, and the next day's =* [Day]'s Anchor Tasks=. Phase 7 writes them, drawing on what earlier phases already surfaced. + +Order doesn't matter between the three sub-steps. They write to distinct sections and don't depend on each other. + +*** Sub-step 7a: Heads-up + +Write a top-level =* Heads-up= section near the top of the prep doc (above =* Day's Priorities=). + +Heads-up is the executive summary of what would change Craig's frame for the day. Terse, situational, day-shaping. Pull from sources earlier phases surfaced: + +- *Substantial FYIs* from Phase 3 sub-steps 3b/3d/3e — items like "Eric quietly progressed two partnership threads on Apr 24" or "Vrezh force-pushed all three branches overnight" that affect what Craig should expect today +- *Schedule changes* from Phase 1 — "Arusyak 15:00-16:00 is being rescheduled" or "DeepSat GTM declined for tonight" +- *Urgent deadlines bubbling up* — items in =* Upcoming Deadlines= that hit within ~2 days, surfaced as standalone Heads-up items so Craig sees them immediately +- *Active Reminders* (from Phase A's notes.org read) that frame today specifically — "first thing in the morning, register for SOFWeek" + +Format: bullet list, one situational note per line, no sub-bullets. Keep each entry to one sentence. If a Heads-up item needs more context, link to the source (email, ticket, prep section) instead of inlining. + +Lightweight FYIs (status pings, "FYI we shipped", small acknowledgments) stay in =.ai/session-context.org= and don't surface in Heads-up. + +*** Sub-step 7b: Upcoming Deadlines + +Write a top-level =* Upcoming Deadlines= section. + +Source: scan todo.org for =DEADLINE:= entries plus deadlines mentioned in notes.org or knowledge.org. Filter to roughly the next 4-6 weeks. Order chronologically. + +Format: bullet list, one deadline per line, format =- [Day YYYY-MM-DD] — short description=. Mention the owner if it isn't Craig and the deadline still concerns him (e.g., "Subbu owns; Craig's technical content due ahead of this"). + +Volume control: if more than ~10 deadlines fit the window, narrow the window or surface only the ones that are blocking, externally-imposed, or have non-trivial prep ahead. + +Don't duplicate deadlines that are already today's =* Day's Priorities= entries. Day's Priorities is for action; Upcoming Deadlines is for awareness. + +*** Sub-step 7c: Next day's Anchor Tasks + +Write a top-level =* [Day]'s Anchor Tasks= section. Compute the next day's name from today's date — *don't* skip weekends. For a Friday prep doc this becomes =* Saturday's Anchor Tasks=, for a Saturday prep doc it becomes =* Sunday's Anchor Tasks=, etc. Use known PTO markers if any (a fully-blocked PTO day can be skipped to the next available day). + +Source: items Craig is explicitly committing to do tomorrow. Pull from: + +- Items planned for today that didn't get done (from Phase 2's Planned vs Actual) +- Items that genuinely fit tomorrow better than today (criterion below) +- Prep work needed before tomorrow's meetings +- Anything Craig flagged during today's session as "I'll do this tomorrow" + +*Criterion for pushing an item to tomorrow rather than fitting it today.* Push only if at least one is true: + +1. *Hard-blocked today* — waiting on a person, a system, or a deadline that hasn't passed yet +2. *Needs a contiguous block* today's window can't provide +3. *Prep work for a tomorrow-only meeting* (do the prep close to the meeting) +4. *Team-dependent* and the team isn't available today (e.g., Craig's at one location and the team's at another) + +If none of those apply, the item stays in today's Day's Priorities and gets a time block in Phase 4. Don't auto-punt independent reading, registration, local-machine work, or "I'll get to it tomorrow" items just because today is a weekend or feels light. + +Items further out than tomorrow (Monday-only items written on a Saturday, for example) stay in =todo.org= with appropriate priority and SCHEDULED markers. Don't list them in the prep doc — they'll resurface in the relevant day's prep via Phase 3 sub-step 3a. + +Format: bullet list or =** TODO= headings, with optional time estimates (e.g., "— 1.5 hrs"). Each item should be a clear task — passive monitoring or context goes elsewhere (Heads-up or session-context). + +This section is the explicit handoff to next-day's Phase 2. If it's empty, write the header with "(none flagged)" so the next day's prep doesn't mistake an empty section for a missing one. + +** Phase 8: Archive Older Prep Docs + +After the new prep doc is written, archive any prep docs in =inbox/= older than yesterday's. Yesterday's prep doc stays in =inbox/= because the new prep doc may still need to reference it (Planned vs Actual, talking-point carry-forward). + +#+begin_src bash +# Move any inbox/*-daily-prep.org file dated before yesterday into the archive. +mv inbox/YYYY-MM-DD-daily-prep.org daily-prep/ +#+end_src + +The archive lives at =daily-prep/= at repo root. Don't put prep docs in =deepsat/meetings/= — the prep doc covers personal calendar, all 1:1s, and all projects, not just DeepSat work. + +If =daily-prep/= doesn't exist yet (new project), create it. If a stale prep doc lives in =assets/= (an older convention), move it to =daily-prep/= as part of this archive pass. + +This step keeps =inbox/= clean. The previous-day's prep is the only one that still has consumers. + +** Phase 9: Project Extension + +If =.ai/project-workflows/daily-prep.org= exists, read and execute its instructions as additional steps appended to this workflow. The project file contains add-ons specific to the project. It is *not* a replacement for this template — it picks up where this workflow's main flow ends. + +Surface the extension once per session ("Project has additional daily-prep steps — running them now") so the choice is visible. + +This is the project-extension hook from [[file:startup.org][startup.org]]'s workflow discovery rule, made explicit at the workflow level so it's not buried in discovery instructions. Runs in both full-prep and standup-only modes. + +* Principles to Follow + +** Prep Supports Action +The goal is for Craig to walk into every meeting and task with what he needs. If prep doesn't lead to better outcomes, it's wasted time. + +** Craig Drives Priorities +Claude surfaces information and proposes priorities, but Craig decides what matters. Don't assume -- ask. + +** Quick Tasks: Just Do Them +If something takes less than 5 minutes, do it during prep rather than scheduling it. Draft the Slack message, create the calendar invite, send the email. + +** Respect the Calendar +When proposing time blocks, respect existing commitments across all calendars. Don't double-book. Account for transition time between meetings. + +** First-Person Perspective +When preparing 1:1 talking points, frame everything from Craig's perspective -- what does Craig need to communicate, ask, or decide? Not what the other person needs. + +* Living Document + +Update this workflow based on what works in actual daily prep sessions. Track learnings below. + +** Updates and Learnings + +*** 2026-02-23: Initial creation +Created during first daily prep session. Validated against tomorrow's schedule (2026-02-24: Vrezh 1:1, Standup/IPM, Product Review, Backlog Planning). + +*** 2026-02-23: Use explicit day references, not relative terms +When referring to past meetings or events, always include the explicit day (e.g., "from Monday's meeting" not "from this morning's meeting"). The prep doc may be written the day before and read the day of -- relative references like "this morning" or "today" become ambiguous. Include the day name so Craig doesn't have to calculate days in his head while focused on making a point. + +*** 2026-02-23: Prep doc output +The prep is written to =inbox/YYYY-MM-DD-daily-prep.org= where the date is the day being prepped for. +The startup workflow checks for this file and asks whether to open it. Note: need to resolve where the daily prep reference in startup.org lives so template syncing doesn't overwrite it (see todo.org task). + +*** 2026-02-23: Check for dependencies between tasks and meetings +Tasks and meetings often depend on each other. SkyFi prep needs to happen before the Vrezh 1:1 so Craig can ask informed questions; Linear learning needs to happen before the backlog planning meeting where they'll use it. Always check for these dependencies and ask Craig when unsure. + +*** 2026-02-23: Energy management matters as much as time management +Front-load high-effort, high-stakes work early in the day (about an hour after waking). Save research, reading, and lighter tasks for late afternoon when energy dips. Craig tries to eat lunch and have coffee/tea around 12:30 PM to sustain energy for the afternoon. This principle should guide time block placement alongside calendar constraints. + +*** 2026-02-23: Link references in the prep doc +When listing tasks, documents, or directories in the prep doc, include links to the source +(todo.org line numbers, file paths, directories). Craig shouldn't have to search for context +when he's in the middle of a meeting or working through the day. + +*** 2026-02-23: Note dependencies between time blocks +When an earlier time block feeds into a later meeting, call it out explicitly in the prep doc +(e.g., "by this point, should have already checked Okta access"). Helps Craig verify he's +on track as the day progresses. + +*** 2026-02-23: Prep doc is a living document through the day +Craig may annotate the prep doc with "cj:" comments as he works through it. Process those +when asked or at the start of the next session. Remove the comment markers after acting on them. + +*** 2026-02-23: Ask probing questions about task nature +When a task like "SkyFi outreach" could mean either a 5-minute email or a 1-hour call, ask. The answer often splits the task into prep + action, which schedule differently. These questions are very helpful and should be a regular part of the workflow. + +*** 2026-03-08: Keep Day's Priorities, drop separate Time Blocking table +The output prep doc should have a "Day's Priorities" section followed by a single chronological +"Meetings / Work Blocks" section that interleaves meetings, prep blocks, and focused work blocks +in time order. No separate Time Blocking table — the chronological Meetings / Work Blocks section +serves that purpose. + +*** 2026-03-09: Day's Priorities use org-mode TODO headings, not numbered lists +Each priority is a =** TODO= heading under =* Day's Priorities=, not a numbered list item. +Format: =** TODO Task name — description. Links to todo.org or other files. ~time estimate.= +Do not use bold or italic markup in the prep doc — org headings, TODO keywords, and plain text +provide enough structure. +Mark completed items as =** DONE= with a =CLOSED:= timestamp on the next line. +Order by urgency and priority — most important/time-sensitive first. Craig should be able to +work top-to-bottom and know he's tackling the right thing next. +This makes priorities trackable in org-mode (agenda, todo filtering) and lets Craig toggle +status directly in Emacs as the day progresses. + +The workflow phases (identify priorities, propose time blocks) still happen during the prep +conversation — the change is only to the output format. + +*** 2026-03-08: Always include Planned vs Actual +Craig finds the Planned vs Actual review table valuable. Always include it in the prep doc +when a previous day's prep doc exists. This is Phase 2 of the workflow and should never be skipped. + +*** 2026-04-01: Always use human-readable ticket titles +When referencing Linear tickets (or any issue tracker IDs), always use the human-readable +title with the ID in parentheses — e.g., "Setup Database for dev environment (SE-93)" not +just "SE-93." Craig can't remember what ticket IDs map to, and bare IDs force him to look +them up. Use the Linear MCP tools to fetch the title if needed. + +*** 2026-03-27: Standup briefs — only team-visible goals, not personal productivity +Only include work that left Craig's local environment: pushed to a repo, shared with +the team, posted in Slack, changed something in Linear, or shifts what the team believes +or plans. Exclude work whose output lives entirely in Craig's local files (knowledge.org, +todo.org, session notes, transcript processing, local tooling setup, MCP server config). +Filter: "If I didn't mention this, would someone on the team make a worse decision or +duplicate the work?" If no, cut it. + +*** 2026-05-12: Day's Priorities entries are thin links to todo.org tasks — never duplicated content +Craig's call. The prep doc's =* Day's Priorities= entries point at todo.org tasks (=** TODO [#X] <title> — [[file:../todo.org::*<title>][todo.org]]= plus a one-line "why today"); all the substance — descriptions, drafts, research, sub-tasks, VERIFY asks, recommended-approach — lives in the matching todo.org task, which Phase 3 creates or updates. Completed-today entries still become dated log headings in the prep doc (the day's record), linking to their durable todo.org home if there is one. Full statement in *Prep Doc Structure ▸ Day's Priorities entries are thin links to todo.org tasks* above. Follow-up: Phase 3 sub-steps 3b–3f still say "add to the prep doc" — reword them to "create/update the todo.org task, link from the prep doc" on the next pass. (Supersedes the 2026-03-09 "Day's Priorities use org-mode TODO headings" entry's implication that the content lives in the prep doc — the headings still use TODO keywords / dated-log headings, but the body is a link.) diff --git a/claude-templates/.ai/workflows/delete-calendar-event.org b/claude-templates/.ai/workflows/delete-calendar-event.org new file mode 100644 index 0000000..5bb92a1 --- /dev/null +++ b/claude-templates/.ai/workflows/delete-calendar-event.org @@ -0,0 +1,190 @@ +#+TITLE: Delete Calendar Event Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-01 + +* Overview + +Workflow for deleting calendar events. Uses the Google Calendar MCP server (preferred) or gcalcli (fallback, personal account only). + +* Triggers + +- "delete the meeting" +- "cancel my appointment" +- "remove the event" +- "clear my calendar for..." + +* Prerequisites + +- Google Calendar MCP server configured and authenticated (=@cocal/google-calendar-mcp=) +- Two accounts available: =personal= (Craig Google) and =work= (Craig Deepsat) +- Fallback: gcalcli installed (personal account only) +- Event must exist on calendar + +* Note: Calendar Visibility + +The MCP server can delete events from both personal and work Google calendars. Proton calendar events are visible in =~/.emacs.d/data/pcal.org= but cannot be modified from here. + +* Workflow Steps + +** 1. Parse User Request + +Extract: +- Which event (title, partial match, or date hint) +- Date context (if provided) + +Examples: +- "Delete the dentist appointment" → search for "dentist" +- "Cancel tomorrow's meeting" → search tomorrow's events +- "Remove the 3pm call" → search by time + +** 2. Search for Event + +*** MCP (preferred) +Use =search-events= or =list-events= MCP tool with appropriate =account_id= ("personal" or "work"). + +*** gcalcli (fallback, personal only) +#+begin_src bash +gcalcli --calendar "Calendar Name" search "event title" +gcalcli --calendar "Calendar Name" agenda "date" "date 11:59pm" +#+end_src + +** 3. Handle Multiple Matches + +If search returns multiple events: + +#+begin_example +Found 3 events matching "meeting": + +1. Team Meeting - Feb 3, 2026 at 9:00 AM +2. Project Meeting - Feb 4, 2026 at 2:00 PM +3. Client Meeting - Feb 5, 2026 at 10:00 AM + +Which event do you want to delete? (1-3) +#+end_example + +** 4. Display Full Event Details + +Show the event that will be deleted: + +#+begin_example +Event to Delete: +================ +Event: Team Meeting +When: Monday, Feb 3, 2026 at 9:00 AM +Duration: 1 hour +Location: Conference Room A +Description: Weekly sync +Calendar: Work +#+end_example + +** 5. Explicit Confirmation + +Ask clearly: + +#+begin_example +Delete this event? (yes/no) +#+end_example + +*Do NOT delete until user explicitly confirms with "yes".* + +** 6. Execute Delete + +*** MCP (preferred) +Use the =delete-event= MCP tool: +- =account_id=: "personal" or "work" +- =calendar_id=: calendar name or ID +- =event_id=: event ID (obtained from search/list results) + +*** gcalcli (fallback, personal only) + +gcalcli delete requires interactive confirmation. Pipe "y" to confirm: + +#+begin_src bash +echo "y" | gcalcli --calendar "Calendar Name" delete "Event Title" +#+end_src + +Use a date range to narrow matches: + +#+begin_src bash +echo "y" | gcalcli --calendar "Calendar Name" delete "Event Title" 2026-02-14 2026-02-15 +#+end_src + +** 7. Verify + +Confirm the event is gone: + +*** MCP +Use =search-events= or =list-events= to verify the event no longer appears. + +*** gcalcli (fallback) +#+begin_src bash +gcalcli --calendar "Calendar Name" search "Event Title" +#+end_src + +Report success or failure to user. + +* Recurring Events + +*Warning:* Deleting a recurring event deletes ALL instances. + +For recurring events: +1. Warn the user that all instances will be deleted +2. Ask for confirmation specifically mentioning "all occurrences" +3. Consider if they only want to delete one instance (not supported by simple delete) + +#+begin_example +This is a recurring event. Deleting it will remove ALL occurrences. + +Delete all instances of "Weekly Standup"? (yes/no) +#+end_example + +* Error Handling + +** Event Not Found +- Verify spelling +- Try partial match +- Check date range +- May have already been deleted + +** Delete Failed +- Check calendar permissions +- Verify event exists +- Try with --calendar flag + +** Wrong Event Deleted +- Cannot undo gcalcli delete +- Would need to recreate the event manually + +* Safety Considerations + +1. *Always show full event details* before asking for confirmation +2. *Never delete without explicit "yes"* from user +3. *Warn about recurring events* before deletion +4. *Verify deletion* by searching after +5. *Read-only calendars* (like Christine's) cannot have events deleted + +* Read-Only Calendars + +Some calendars are read-only: + +| Calendar | Can Delete? | Account | +|---------------------------+-------------+----------| +| Craig Google | Yes | personal | +| Christine | Yes | personal | +| Craig Deepsat | Yes | work | +| Todoist | Yes | personal | +| Craig Jennings (TripIt) | No | personal | +| Holidays in United States | No | personal | +| Craig Proton | No | personal | + +If user tries to delete from read-only calendar: + +#+begin_example +Cannot delete from "Craig Proton" - this is a read-only calendar. +#+end_example + +* Related + +- [[file:add-calendar-event.org][Add Calendar Event]] - create events +- [[file:read-calendar-events.org][Read Calendar Events]] - view events +- [[file:edit-calendar-event.org][Edit Calendar Event]] - modify events diff --git a/claude-templates/.ai/workflows/edit-calendar-event.org b/claude-templates/.ai/workflows/edit-calendar-event.org new file mode 100644 index 0000000..662f0b4 --- /dev/null +++ b/claude-templates/.ai/workflows/edit-calendar-event.org @@ -0,0 +1,186 @@ +#+TITLE: Edit Calendar Event Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-01 + +* Overview + +Workflow for editing existing calendar events. Uses the Google Calendar MCP server (preferred) or gcalcli (fallback, personal account only). + +The MCP server supports direct event updates via =update-event= — no delete-and-recreate needed. gcalcli fallback still uses delete-and-recreate since gcalcli's edit command is interactive. + +* Triggers + +- "edit the meeting" +- "change my appointment" +- "reschedule" +- "update the event" +- "move my appointment" + +* Prerequisites + +- Google Calendar MCP server configured and authenticated (=@cocal/google-calendar-mcp=) +- Two accounts available: =personal= (Craig Google) and =work= (Craig Deepsat) +- Fallback: gcalcli installed (personal account only) +- Event must exist on calendar + +* CRITICAL: Check All Calendars Before Rescheduling + +When rescheduling an event, ALWAYS check for conflicts at the new time across ALL calendars: + +1. *MCP server* — check both personal and work accounts via =list-events= or =get-freebusy= +2. *Emacs org files* — for Proton calendar (not accessible via MCP or gcalcli): + +#+begin_src bash +grep "TARGET_DATE" ~/.emacs.d/data/pcal.org # Proton calendar +#+end_src + +Verify the new time is free across all calendars before rescheduling. + +* Workflow Steps + +** 1. Parse User Request + +Extract: +- Which event (title, partial match, or date hint) +- What to change (if mentioned) + +Examples: +- "Edit the dentist appointment" → search for "dentist" +- "Reschedule tomorrow's meeting" → search tomorrow's events +- "Change the 3pm call to 4pm" → search by time + +** 2. Search for Event + +*** MCP (preferred) +Use =search-events= or =list-events= MCP tool with appropriate =account_id= ("personal" or "work"). + +*** gcalcli (fallback, personal only) +#+begin_src bash +gcalcli --calendar "Calendar Name" search "event title" +gcalcli --calendar "Calendar Name" agenda "date" "date 11:59pm" +#+end_src + +** 3. Handle Multiple Matches + +If search returns multiple events: + +#+begin_example +Found 3 events matching "meeting": + +1. Team Meeting - Feb 3, 2026 at 9:00 AM +2. Project Meeting - Feb 4, 2026 at 2:00 PM +3. Client Meeting - Feb 5, 2026 at 10:00 AM + +Which event do you want to edit? (1-3) +#+end_example + +** 4. Display Full Event Details + +Show the current event state: + +#+begin_example +Event: Team Meeting +When: Monday, Feb 3, 2026 at 9:00 AM +Duration: 1 hour +Location: Conference Room A +Description: Weekly sync +Reminders: 5 min, 0 min +Calendar: Craig +#+end_example + +** 5. Ask What to Change + +Options: +- Title +- Date/Time +- Duration +- Location +- Description +- Reminders + +Can change one or multiple fields. + +** 6. Show Updated Summary + +Before applying changes: + +#+begin_example +Updated Event: +Event: Team Standup (was: Team Meeting) +When: Monday, Feb 3, 2026 at 9:30 AM (was: 9:00 AM) +Duration: 30 minutes (was: 1 hour) +Location: Conference Room A +Description: Weekly sync +Reminders: 5 min, 0 min +Calendar: Craig + +Apply these changes? (yes/no) +#+end_example + +** 7. Explicit Confirmation + +*Do NOT apply changes until user confirms.* + +** 8. Execute Edit + +*** MCP (preferred — direct update) +Use the =update-event= MCP tool: +- =account_id=: "personal" or "work" +- =calendar_id=: calendar name or ID +- =event_id=: event ID (from search/list results) +- Only pass the fields that changed (summary, start, end, location, description, etc.) + +*** gcalcli (fallback, personal only — delete + recreate) + +Since gcalcli edit is interactive, use delete + add: + +#+begin_src bash +# Delete original +gcalcli --calendar "Calendar Name" delete "Event Title" --iamaexpert + +# Recreate with updated fields +gcalcli --calendar "Calendar Name" add \ + --title "Updated Title" \ + --when "new date/time" \ + --duration NEW_MINUTES \ + --where "Location" \ + --description "Description" \ + --reminder 5 \ + --reminder 0 \ + --noprompt +#+end_src + +*Warning:* The gcalcli delete+recreate approach deletes ALL instances of a recurring event. The MCP =update-event= tool handles this more gracefully. + +** 9. Verify + +*** MCP +Use =search-events= or =get-event= to verify the update. + +*** gcalcli (fallback) +#+begin_src bash +gcalcli --calendar "Calendar Name" search "Updated Title" +#+end_src + +Report success or failure. + +* Error Handling + +** Event Not Found +- Verify spelling +- Try partial match +- Check date range + +** Multiple Matches +- Show all matches +- Ask user to select one +- Use more specific search terms + +** MCP Authentication Error +Use =manage-accounts= MCP tool with =action: "add"= to re-authenticate. + +* Related + +- [[file:add-calendar-event.org][Add Calendar Event]] - create events +- [[file:read-calendar-events.org][Read Calendar Events]] - view events +- [[file:delete-calendar-event.org][Delete Calendar Event]] - remove events diff --git a/claude-templates/.ai/workflows/email-assembly.org b/claude-templates/.ai/workflows/email-assembly.org new file mode 100644 index 0000000..003459c --- /dev/null +++ b/claude-templates/.ai/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/claude-templates/.ai/workflows/extract-email.org b/claude-templates/.ai/workflows/extract-email.org new file mode 100644 index 0000000..3a70bea --- /dev/null +++ b/claude-templates/.ai/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 .ai/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 .ai/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: =.ai/scripts/eml-view-and-extract-attachments.py= diff --git a/claude-templates/.ai/workflows/find-email.org b/claude-templates/.ai/workflows/find-email.org new file mode 100644 index 0000000..0ef9615 --- /dev/null +++ b/claude-templates/.ai/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/claude-templates/.ai/workflows/first-session.org b/claude-templates/.ai/workflows/first-session.org new file mode 100644 index 0000000..60118a2 --- /dev/null +++ b/claude-templates/.ai/workflows/first-session.org @@ -0,0 +1,87 @@ +#+TITLE: First Session Workflow +#+AUTHOR: Craig Jennings & Claude + +Run this workflow on the first Claude Code session for a new +project. It establishes the git/.ai policy, orients Claude to the +project, and initializes =.ai/notes.org=. + +* When to Run + +When any of these are true: +- =.ai/notes.org= contains the "If this is the first session" pointer +- =.ai/sessions/= doesn't exist or is empty (no prior session records) +- User says "this is a new project" or "let's set this project up" + +If unsure, ask. + +* The Workflow + +** Step 1: Determine git / =.ai= policy + +Ask: +- Is this project in a git repository? +- What are the remote repositories (if any)? +- Is this a *code project* (Emacs package, library, software project) + or a *content/documentation project* (personal planning, business, + reference)? + +Based on the answer: + +*** Code project +- Add =/.ai/= to =.gitignore= — session tooling is private, not part + of the codebase +- Examples: org-msg, chime.el, wttrin, or any future Emacs + packages/libraries +- =.ai/= contains session notes and Claude tooling; stays local-only +- A project-level =docs/= (if ever created) is still tracked — real + user-facing docs go there, not in =.ai/= + +*** Content / documentation project +- Commit =.ai/= normally — the project history IS the project +- Examples: personal projects, business planning, documentation, + reference collections +- =.ai/= holds session context and reference material that's part of + the project's evolution + +** Step 2: Understand the project + +Ask: +- What is this project about? +- What are the goals? +- Any background, constraints, or people involved? +- Anything that's already tried / ruled out? + +Take clarifying questions as they arise. Don't try to finish this +step before moving on — understanding deepens across the first few +sessions. + +** Step 3: Brainstorm how to help + +- Discuss approaches and strategies +- Identify immediate next steps +- Agree on a first concrete task (or that the first session is just + orientation) + +** Step 4: Document what was learned + +- Fill in the *Project-Specific Context* section of =.ai/notes.org= + with the project overview, goals, and any key facts from Step 2 +- Add project-specific references or files to =.ai/= as needed +- If the project has a task file (=todo.org= at root), note its + location in notes.org + +** Step 5: Clean up + +- Remove the "If this is the first session" pointer from + =.ai/notes.org= (it's done its job) +- The first session's record will be archived automatically via + wrap-it-up.org at session end — the session-context.org file + becomes =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. No + additional notes.org transcription needed. + +* Rationale + +First-session setup is a one-time event per project but it's +procedural, so it belongs in workflows/ rather than embedded in the +notes.org seed. The seed notes.org points here on first session; +subsequent sessions should never touch this workflow. diff --git a/claude-templates/.ai/workflows/journal-entry.org b/claude-templates/.ai/workflows/journal-entry.org new file mode 100644 index 0000000..6fc5a73 --- /dev/null +++ b/claude-templates/.ai/workflows/journal-entry.org @@ -0,0 +1,218 @@ +#+TITLE: Journal Entry Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2025-11-07 + +* Overview + +This workflow captures the day's work in Craig's personal journal. Journal entries serve as a searchable record for retrospectives, timelines, and trend analysis, while also providing context to Claude about relationships, priorities, mood, and goals that improve our collaboration. + +* Problem We're Solving + +Without regular journal entries, several problems emerge: + +** Limited Memory and Searchability +- Craig's memory is limited, but what's recorded is always available +- Finding when specific events occurred becomes difficult +- Creating project timelines and retrospectives requires manual reconstruction +- Identifying work patterns (weekday vs weekend, morning vs evening) is impossible + +** Missing Context for Collaboration +- Claude lacks understanding of relationships (Julie is Craig's aunt, Laura is his sister) +- Important contextual details that seem minor become critical unexpectedly +- Craig's mood, frustrations, and satisfaction levels remain hidden +- What Craig finds important vs unimportant isn't explicitly communicated +- Claude can't identify where to help Craig focus attention to avoid mistakes + +** Lost Insights +- Decisions made and reasoning behind them aren't captured +- Big picture goals and upcoming plans remain undocumented +- Patterns in what Craig is good at vs struggles with aren't tracked + +*Impact:* Without journal entries, Craig loses valuable personal records and Claude operates with incomplete context, reducing collaboration effectiveness. + +* Exit Criteria + +We know a journal entry is complete when: + +1. **Draft has been created** - Claude writes initial first-person draft based on today's session record (=.ai/session-context.org= if session is live, or today's file in =.ai/sessions/= if already wrapped up) +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 + +*Measurable validation:* +- Journal entry exists in the daily journal file +- Craig has approved the final text +- Entry captures big decisions, accomplishments, and unusual details +- Tone feels personal, vulnerable, and story-like + +* When to Use This Session + +Trigger this workflow when: + +- Craig says "let's do a journal entry" or "create a journal entry" +- At the end of a work session, particularly in the evening +- Craig asks to wrap up the day +- After completing significant work on a project + +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 today's session record for the day's activities: +- If session is still live: read =.ai/session-context.org= (both Summary and Session Log) +- If session already wrapped: read today's file in =.ai/sessions/= (named =YYYY-MM-DD-HH-MM-description.org=) + +Pull out: +- Accomplishments achieved +- Decisions made +- Meetings or calls attended +- Files created or organized +- Actions planned for the future +- Outstanding items + +** Step 2: Draft the Journal Entry + +Write a first-person journal entry as Craig. The entry should: +- Be 2-3 paragraphs (unless it's an unusually eventful day) +- Focus on big ideas and decisions +- Include unusual or notable details +- Read like a personal journal - it's a little story about how things went +- Use a tone that's personal, genuine, and vulnerably open (never emotional) + +Structure suggestions: +- Start with the big event or decision of the day +- Explain what led to that decision or what work was accomplished +- Include any context about people, mood, or upcoming plans +- End with what's next or how you're feeling about progress + +** Step 3: Display and Request Revisions + +Display the draft to Craig and ask: "Does this capture the day? What would you like me to adjust?" + +This is where important context emerges: +- Corrections about relationships and people +- Clarification of goals and motivations +- Craig's mood and feelings about events +- Plans for the future +- What's important vs not important + +** Step 4: Incorporate Feedback and Iterate + +Make the requested changes and display the revised text. Ask again for revisions. Repeat this process until Craig approves or indicates no more changes are needed. + +During revisions: +- Ask questions if unsure about tone or word choice +- Ask about people mentioned for the first time +- If someone behaves strangely, ask Craig's thoughts to find the right tone +- Record any new context in your notes for future reference + +** Step 5: Add Entry to Journal File + +Once approved: + +1. Find the org-roam daily journal file at ~/sync/org/roam/journal/YYYY-MM-DD.org +2. If it doesn't exist, create it with this header: + ``` + :PROPERTIES: + :ID: [generate UUID using uuidgen] + :END: + #+FILETAGS: Journal + #+TITLE: YYYY-MM-DD + ``` +3. Create a top-level org header with timestamp: + ``` + * YYYY-MM-DD Day @ HH:MM:SS TZ ProjectName - What Kind of Day Has It Been? + ``` + (Get timezone with: date +%z) +4. Add the approved journal text below the header + +** Step 6: Wrap Up + +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. + +* Principles to Follow + +** Personal and Vulnerable +- Write in a genuinely open, vulnerable tone +- Never emotional, but honest about challenges and feelings +- Make it feel like Craig's personal journal, not a work report + +** Brief but Complete +- Default to 2-3 paragraphs +- Capture big ideas and unusual details +- Don't document every minor task +- Longer entries are fine for unusually eventful days + +** Story-Like Quality +- Read like someone telling a story about their day +- Have a narrative flow, not just a bullet list +- Connect events and decisions with context + +** Clarifying Questions Welcome +- Ask about tone, word choice, or what to include when unsure +- Ask about people mentioned for the first time +- Probe for Craig's thoughts when events seem unusual +- Use questions to gather context that improves collaboration + +** Context Capture +- Record new information about relationships, goals, and preferences +- Note what Craig finds important vs unimportant +- Track mood indicators and patterns +- Save insights for future reference + +** Use Session Data +- Start from today's session record (=.ai/session-context.org= if live, or today's =.ai/sessions/= file if wrapped) +- Don't rely on memory - check the documented record +- Include key decisions, accomplishments, and next steps + +* Living Document + +This is a living document. As we create journal entries and learn what works well, we update this file with: + +- Improvements to the drafting approach +- Better examples of tone and style +- Additional principles discovered +- Refinements based on Craig's feedback + +Every journal entry is an opportunity to improve this workflow. + +* Example Journal Entry + +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. + +This morning I woke up to two counter offers - one from Cortney Arambula at $1,377,000 and another from Rolando Tong Jr. at $1,405,000. Deadline was 3 PM today. + +I had two phone calls with Craig Ratowsky. The first at 11:59 AM, we talked through both offers. Rolando's was clearly better - $28,000 more, already pre-approved, and the buyer is his sister. Craig walked me through the numbers and timeline. + +On the second call at 12:25 PM, I made the decision: accept Rolando's offer at $1,405,000. After all these months of work - dealing with mold, replacing the kitchen, new flooring, staging - we have a buyer. + +Escrow opens Monday (11/10/2025), 30-day close from there. By mid-December, this will be done. + +Net proceeds to the trust will be around $1,099,385 after the mortgage payoff, closing costs, and agent commissions. + +I spent the early evening getting all the files organized so I can figure out exactly how much Christine and I put in for the renovation and get reimbursed. This will also help when I report expenses to Mom and Laura about the estate. + +Now I need to plan the trip to Huntington Beach to handle Gogo's financial affairs - consolidate her accounts into the estate account, pay her bills, distribute her funds, and mail some items from the garage back home. Plus empty the garage for the seller before closing. + +Escrow Monday. Still need to: +- Decide on compensating Craig and Justin for their extra work +- Get the Tax ID number for the estate +- Work on Gogo's final taxes with a CPA +- File the Inventory & Appraisal with probate court + +It's been almost nine months since Gogo passed. Getting this condo sold feels like a huge milestone. +#+end_quote + +Note the personal tone, narrative flow, big decision (accepting the offer), context about people (Gogo, Craig Ratowsky, Christine), mood (milestone feeling), and what's next. diff --git a/claude-templates/.ai/workflows/page-me.org b/claude-templates/.ai/workflows/page-me.org new file mode 100644 index 0000000..607ed51 --- /dev/null +++ b/claude-templates/.ai/workflows/page-me.org @@ -0,0 +1,173 @@ +#+TITLE: Page Me Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-01-31 +#+UPDATED: 2026-02-27 + +* 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. + +* Trigger Phrase + +Craig says *"page me"* (or variations like "page me in 10 minutes", "page me at 3pm"). + +The word "page" is the trigger for this workflow. It means: set a timed notification. + +Previously called "set-alarm" -- renamed to "page-me" for a distinctive, short trigger phrase that won't collide with common words like "remind" or "alert." + +* 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 says "page me" 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: +- "Page me at 5pm to wrap up" +- "Page me in 30 minutes to check the build" +- "Page me in 1 hour - time to take a break" + +* Approach: How We Work Together + +** Step 1: Craig Requests a Page + +Craig tells Claude when and why: +- "Page me in 45 minutes - meeting starts" +- "Page me at 3:30pm to call the dentist" + +** Step 2: Claude Sets the Page + +Claude schedules the alarm using the =at= daemon with =notify=: + +#+begin_src bash +echo "notify alarm 'Page' 'Time to call the dentist' --persist" | at 3:30pm +echo "notify alarm 'Page' '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 for specific time +echo "notify alarm 'Page' 'Meeting starts' --persist" | at 3:30pm + +# Schedule for relative time +echo "notify alarm 'Page' 'Check the build' --persist" | at now + 30 minutes + +# Schedule for tomorrow +echo "notify alarm 'Page' '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 "Page" "Your message here" --persist +#+end_src + +The =--persist= flag keeps the notification on screen until manually dismissed. All page-me notifications should use =--persist= by default. + +** 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/claude-templates/.ai/workflows/process-meeting-transcript.org b/claude-templates/.ai/workflows/process-meeting-transcript.org new file mode 100644 index 0000000..07f8f3e --- /dev/null +++ b/claude-templates/.ai/workflows/process-meeting-transcript.org @@ -0,0 +1,306 @@ +#+TITLE: Process Meeting Transcript Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-03 + +* Overview + +This workflow defines the process for processing meeting recordings from start to finish: finding recordings, extracting audio, transcribing via AssemblyAI, identifying speakers, correcting errors, and archiving files. + +* When to Use This Workflow + +Trigger this workflow when: +- Craig says "process the transcript" or "process the recording" or similar +- New recording files (.mkv) appear in ~/sync/recordings/ after meetings +- Craig wants to process meeting recordings into labeled transcripts + +* Prerequisites + +- Recording file(s) exist in ~/sync/recordings/ (*.mkv) +- Calendar files available at ~/.emacs.d/data/*cal.org for meeting titles +- AssemblyAI transcription script at ~/.emacs.d/scripts/assemblyai-transcribe +- AssemblyAI API key stored in ~/.authinfo.gpg (machine api.assemblyai.com) +- ffmpeg available for audio extraction + +* The Workflow + +** Step 1: Identify Engagement and Write Session Context + +Before starting transcript processing: + +1. *Identify which engagement this meeting belongs to:* + - DeepSat (default for current work) + - Vineti (historical) + - Salesforce (historical) + - If unclear, ask Craig + +2. *Set destination paths based on engagement:* + - Assets: ~{engagement}/assets/~ (e.g., ~deepsat/assets/~) + - Meetings: ~{engagement}/meetings/~ (e.g., ~deepsat/meetings/~) + - Knowledge: ~{engagement}/knowledge.org~ for reference + +3. Update .ai/session-context.org with current status: + - Note that we're about to process a meeting transcript + - Get meeting name by checking ~/.emacs.d/data/*cal.org (match date/time to transcript timestamp) + - If meeting not found in calendar, ask Craig for the meeting title + +** Step 2: Find Recording Files + +Find and match recording files with calendar events. *Run sub-steps 1 and 3 (recording list + calendar dump) as a single parallel batch* — they're independent. Sub-step 2 (parse timestamps) and sub-step 4 (matching) work from those two outputs in-memory, so they're sequential after the batch. + +1. **List recordings:** Find all recording files in ~/sync/recordings/ (video .mkv or audio-only .m4a) + #+begin_src bash + ls -la ~/sync/recordings/*.mkv ~/sync/recordings/*.m4a 2>/dev/null + #+end_src + Audio-only recordings (.m4a) are used when no screen content is expected. These skip Step 3 (audio extraction) since they're already in a transcribable format. + +2. **Extract timestamps:** Parse date/time from each filename (format: YYYY-MM-DD-HH-MM-SS.mkv or .m4a) + +3. **Match with calendar:** Check ~/.emacs.d/data/*cal.org for meetings at those times + #+begin_src bash + cat ~/.emacs.d/data/dcal.org | grep -A2 "YYYY-MM-DD" + #+end_src + +4. **Present selection table to Craig:** + | Filename | Meeting / Date-Time | + |-----------------------------+--------------------------------| + | 2026-02-03_10-00-00.mkv | DeepSat Standup (from calendar)| + | 2026-02-03_14-30-00.mkv | 2026-02-03 14:30 (no match) | + +5. **Craig selects files:** One, several, or all files to process + +6. **Queue for processing:** Selected files ordered oldest → newest for serial processing + +** Step 3: Extract Audio (video recordings only) + +*Skip this step for .m4a files* — they are already audio and can go directly to transcription. + +For .mkv video recordings, extract audio for transcription: + +#+begin_src bash +ffmpeg -i ~/sync/recordings/FILENAME.mkv -vn -ac 1 -c:a aac -b:a 96k /tmp/FILENAME.m4a +#+end_src + +Settings: +- =-vn= : no video (audio only) +- =-ac 1= : mono channel (sufficient for speech, smaller file) +- =-c:a aac= : AAC codec +- =-b:a 96k= : 96kbps bitrate (sufficient for speech transcription) + +Output: /tmp/FILENAME.m4a (temporary, deleted after transcription) + +** Step 4: Transcribe with AssemblyAI + +1. **Run transcription:** + #+begin_src bash + # For .mkv files (audio was extracted to /tmp/): + ~/.emacs.d/scripts/assemblyai-transcribe /tmp/FILENAME.m4a > ~/sync/recordings/FILENAME.txt + # For .m4a files (transcribe directly): + ~/.emacs.d/scripts/assemblyai-transcribe ~/sync/recordings/FILENAME.m4a > ~/sync/recordings/FILENAME.txt + #+end_src + +2. **Clean up:** Delete intermediate .m4a file after successful transcription (only for .mkv extractions — do NOT delete original .m4a recordings) + #+begin_src bash + rm /tmp/FILENAME.m4a + #+end_src + +3. **Output format:** The script produces speaker-diarized output: + #+begin_example + Speaker A: First speaker's text here. + Speaker B: Second speaker's response. + Speaker A: First speaker continues. + #+end_example + +4. Continue to speaker identification workflow below. + +** Step 5: Locate Files + +Confirm the transcript and recording files are ready: + +1. **Verify transcript exists:** + #+begin_src bash + ls -la ~/sync/recordings/FILENAME.txt + #+end_src + +2. **Verify recording exists:** + #+begin_src bash + ls -la ~/sync/recordings/FILENAME.mkv + #+end_src + +3. **Get meeting title:** If not already known from Step 2, check calendar + - Calendar location: ~/.emacs.d/data/*cal.org + - Match the meeting time to the transcript timestamp + +** Step 6: Read and Analyze Transcript + +1. Read the full transcript file + +2. Identify speakers by analyzing context clues: + - Names mentioned in conversation ("Thanks, Ryan") + - Role references ("as the developer", "on the IT side") + - Project-specific knowledge (who works on what) + - Previous meeting context (known attendees) + - Speaking order patterns + +3. Build a speaker identification table: + | Speaker | Person | Evidence | + |---------|--------|----------| + | A | Name | Clues... | + +** Step 7: Confirm Speaker Identifications + +Present the speaker identification table to Craig for confirmation: +- List each speaker label and proposed name +- Include the evidence/reasoning +- Ask about any uncertain identifications +- Note any new people to add to notes.org contacts + +** Step 8: Create Labeled Transcript + +1. Replace all speaker labels with actual names + +2. Correct transcription errors: + - Common mishearings (names, technical terms, company names) + - Known substitutions from this project: + - "Vanetti" → "Vineti" + - "Fresh" → "Vrezh" + - "Clean4" / "clone" → "CLIN 4" + - "Vascan" → "Vazgan" + - "Hike" / "Ike" → "Hayk" + - "High Tech" → "HyeTech" + - "Java software" → "JAMA software" + - "JSON" (person) → "Jason" + - "their S" / "ress" → "Nerses" + - Technical terms specific to DeepSat (GovCloud, AFRL, SOUTHCOM, etc.) + +3. Save to engagement assets folder: + - Location: ~{engagement}/assets/~ (e.g., ~deepsat/assets/~) + - Filename: YYYY-MM-DD-meeting-name.txt + - Example: deepsat/assets/2026-02-03-standup-ipm-grooming.txt + +** Step 9: Copy Recording to Meetings Folder + +1. Ensure engagement meetings folder exists and patterns are in .gitignore (~*/meetings/*.mkv~ and ~*/meetings/*.m4a~) + +2. Copy the recording file with descriptive name: + #+begin_src bash + # Video recordings: + cp ~/sync/recordings/YYYY-MM-DD-HH-MM-SS.mkv {engagement}/meetings/YYYY-MM-DD_HH-MM-meeting-name.mkv + # Audio-only recordings: + cp ~/sync/recordings/YYYY-MM-DD-HH-MM-SS.m4a {engagement}/meetings/YYYY-MM-DD_HH-MM-meeting-name.m4a + #+end_src + Example: ~deepsat/meetings/2026-02-03_11-02-standup-ipm-grooming.mkv~ + +3. Verify the copy succeeded + +** Step 10: Update Session Context with Meeting Summary + +Add a meeting summary section to .ai/session-context.org including: + +1. **Attendees** - List all participants + +2. **Key Decisions** - Important choices made + +3. **Action Items** - Tasks assigned, especially for Craig + +4. **New Information** - Things learned that should be noted + +5. **New Contacts** - People to add to notes.org + +** Step 11: Write Session Context File + +Update .ai/session-context.org with: +- Files created this session (transcript, recording) +- Summary of what was processed +- Next steps (file to assets, update notes.org, etc.) + +*** Context Management (for multiple files) + +When processing multiple recordings in a queue: + +1. **After completing each file's workflow**, update .ai/session-context.org with: + - Files processed so far + - Current position in queue + - Summary of meeting just processed + +2. **Ask Craig if compact is needed** before starting next file: + - Transcript processing uses significant context + - Compacting preserves session context for recovery + +3. **If autocompact occurs**, reread session-context.org to: + - Resume at correct position in queue + - Avoid reprocessing already-completed files + +** Step 12: Clean Up Source Files + +After successful completion of all previous steps, delete the source files from ~/sync/recordings/: + +1. **Delete the original recording:** + #+begin_src bash + rm ~/sync/recordings/FILENAME.mkv + #+end_src + +2. **Delete the raw transcript** (if generated): + #+begin_src bash + rm ~/sync/recordings/FILENAME.txt + #+end_src + +This step happens last to ensure all files are safely copied/processed before deletion. If anything goes wrong earlier in the workflow, the source files remain intact for retry. + +* Output Files + +| File | Location | Purpose | +|--------------------+-------------------------------------------------------+------------------------------------| +| Labeled transcript | {engagement}/assets/YYYY-MM-DD-meeting-name.txt | Corrected transcript for reference | +| Meeting recording | {engagement}/meetings/YYYY-MM-DD_HH-MM-meeting-name.mkv | Video for review (gitignored) | +| Session context | .ai/session-context.org | Crash recovery, meeting summary | +| Knowledge base | {engagement}/knowledge.org | Team, infrastructure, corrections | + +* Common Transcription Errors + +Keep this list updated as new patterns emerge: + +| Heard As | Correct | Context | +|---------------+---------------+------------------------------------------------| +| Vanetti | Vineti | Company where Craig, Nerses, Eric, Ryan worked | +| Fresh | Vrezh | Developer name | +| Clean4, clone | CLIN 4 | Contract milestone | +| Vascan | Vazgan | MagicalLabs AI team member | +| Hike, Ike | Hayk | CTO name | +| High Tech | HyeTech | Armenian tech community org | +| Java software | JAMA software | Requirements traceability tool | +| JSON (person) | Jason | DevSecOps or advisor | +| their S, ress | Nerses | CEO name | +| sir Keith | Sarkis | BD/investor relations | +| Fastgas | MagicalLabs | Armenian AI contractor | +| Sitelix | Cytellix | CMMC security/compliance partner | + +* Tips + +1. **Read the whole transcript first** - Context from later in the meeting often helps identify speakers from earlier + +2. **Use the calendar** - Meeting names help set expectations for who attended + +3. **Check engagement knowledge.org** - Team roster and transcription corrections specific to this engagement + +4. **Ask about unknowns** - If a new person appears, ask Craig for context + +5. **Note new learnings** - Update engagement knowledge.org with new contacts, corrections, or context after processing + +* Validation Checklist + +- [ ] Engagement identified and destination paths set +- [ ] Session context written before starting +- [ ] Recording files listed and matched with calendar +- [ ] Craig selected files to process +- [ ] Audio extracted to .m4a (mono, 96k AAC) +- [ ] AssemblyAI transcription completed +- [ ] Intermediate .m4a file deleted +- [ ] Transcript file verified +- [ ] All speakers identified +- [ ] Speaker identifications confirmed with Craig +- [ ] Transcript corrected and saved to {engagement}/assets/ +- [ ] Recording copied to {engagement}/meetings/ with proper name +- [ ] Session context updated with meeting summary +- [ ] New contacts/info flagged for {engagement}/knowledge.org update +- [ ] (If multiple files) Queue position tracked in session context +- [ ] Source files deleted from ~/sync/recordings/ diff --git a/claude-templates/.ai/workflows/read-calendar-events.org b/claude-templates/.ai/workflows/read-calendar-events.org new file mode 100644 index 0000000..be66bf4 --- /dev/null +++ b/claude-templates/.ai/workflows/read-calendar-events.org @@ -0,0 +1,216 @@ +#+TITLE: Read Calendar Events Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-01 + +* Overview + +Workflow for viewing and querying calendar events. Uses the Google Calendar MCP server (preferred) or gcalcli (fallback, personal account only). + +* Triggers + +- "what's on my calendar" +- "show me appointments" +- "summarize my schedule" +- "what do I have today" +- "calendar for this week" +- "any meetings tomorrow" + +* Prerequisites + +- Google Calendar MCP server configured and authenticated (=@cocal/google-calendar-mcp=) +- Two accounts available: =personal= (Craig Google) and =work= (Craig Deepsat) +- Fallback: gcalcli installed (personal account only) + +* CRITICAL: Cross-Calendar Visibility + +The MCP server has access to both personal and work Google calendars. Use =list-events= with the appropriate =account_id= ("personal" or "work") to see events from each. + +For a complete picture, also check the Proton calendar (not accessible via MCP or gcalcli): + +#+begin_src bash +grep "2026-02-18" ~/.emacs.d/data/pcal.org # Proton calendar +#+end_src + +*ALWAYS check Proton calendar* alongside MCP results when showing a full schedule or checking availability. + +* Workflow Steps + +** 1. Parse Time Range + +Interpret the user's request to determine date range: + +| Request | Interpretation | +|--------------------+-------------------------------| +| "today" | Today only | +| "tomorrow" | Tomorrow only | +| "this week" | Next 7 days | +| "next week" | 7-14 days from now | +| "this month" | Rest of current month | +| "April 2026" | That entire month | +| "next Tuesday" | That specific day | +| "the 15th" | The 15th of current month | + +*No fixed default* - interpret from context. If unclear, ask. + +** 2. Determine Calendar Scope + +Options: +- All calendars (default — query both MCP accounts + Proton org file) +- Personal only: =account_id: "personal"= +- Work only: =account_id: "work"= + +** 3. Query Calendar + +*** MCP (preferred) +Use =list-events= MCP tool with: +- =account_id=: "personal" or "work" (query both for full picture) +- =time_min=, =time_max=: ISO 8601 datetime range +- =calendar_id=: specific calendar (optional, defaults to all) + +Also use =get-freebusy= to quickly check availability across calendars. + +*** gcalcli (fallback, personal only) +#+begin_src bash +gcalcli agenda "start_date" "end_date" +gcalcli calw # weekly view +gcalcli calm # monthly view +#+end_src + +** 4. Format Results + +Present events in a readable format: + +#+begin_example +=== Tuesday, February 4, 2026 === + +9:00 AM - 10:00 AM Team Standup + Location: Conference Room A + +2:00 PM - 3:00 PM Dentist Appointment + Location: Downtown Dental + +=== Wednesday, February 5, 2026 === + +(No events) + +=== Thursday, February 6, 2026 === + +10:00 AM - 11:30 AM Project Review + Location: Zoom +#+end_example + +** 5. Summarize + +Provide a brief summary: +- Total number of events +- Busy days vs free days +- Any all-day events +- Conflicts (if any) + +* gcalcli Command Reference + +** Agenda View + +#+begin_src bash +# Default agenda (next few days) +gcalcli agenda + +# Today only +gcalcli agenda "today" "today 11:59pm" + +# This week +gcalcli agenda "today" "+7 days" + +# Specific date range +gcalcli agenda "2026-03-01" "2026-03-31" + +# Specific calendar +gcalcli --calendar "Work" agenda "today" "+7 days" +#+end_src + +** Calendar Views + +#+begin_src bash +# Weekly calendar (visual) +gcalcli calw + +# Monthly calendar (visual) +gcalcli calm + +# Multiple weeks +gcalcli calw 2 # Next 2 weeks +#+end_src + +** Search + +#+begin_src bash +# Search by title +gcalcli search "meeting" + +# Search specific calendar +gcalcli --calendar "Work" search "standup" +#+end_src + +* Output Formats + +gcalcli supports different output formats: + +| Option | Description | +|------------------+--------------------------------| +| (default) | Colored terminal output | +| --nocolor | Plain text | +| --tsv | Tab-separated values | + +* Time Range Examples + +| User Says | gcalcli Command | +|------------------------+----------------------------------------------| +| "today" | agenda "today" "today 11:59pm" | +| "tomorrow" | agenda "tomorrow" "tomorrow 11:59pm" | +| "this week" | agenda "today" "+7 days" | +| "next week" | agenda "+7 days" "+14 days" | +| "February" | agenda "2026-02-01" "2026-02-28" | +| "next 3 days" | agenda "today" "+3 days" | +| "rest of the month" | agenda "today" "2026-02-28" | + +* Calendars + +| Calendar | Access | Account | Notes | +|---------------------------+--------+----------+--------------------------------| +| Craig Google | owner | personal | Default personal calendar | +| Christine | owner | personal | Christine's calendar | +| Craig Deepsat | owner | work | DeepSat work calendar | +| Todoist | owner | personal | Todoist integration | +| Craig Jennings (TripIt) | reader | personal | View only | +| Holidays in United States | reader | personal | View only | +| Craig Proton | reader | personal | View only (no API access) | + +* Handling No Events + +If the date range has no events: +- Confirm the range was correct +- Mention the calendar is free +- Offer to check a different range + +Example: "No events found for tomorrow (Feb 3). Your calendar is free that day." + +* Error Handling + +** No Events Found +Not an error - calendar may simply be free. + +** MCP Authentication Error +Use =manage-accounts= MCP tool with =action: "add"= to re-authenticate. + +** gcalcli Authentication Error +Run =gcalcli init= to re-authenticate. + +** Invalid Date Range +MCP: Use ISO 8601 format: =YYYY-MM-DDTHH:MM:SS±HH:MM= +gcalcli: Use explicit dates: =YYYY-MM-DD= + +* Related + +- [[file:add-calendar-event.org][Add Calendar Event]] - create events +- [[file:edit-calendar-event.org][Edit Calendar Event]] - modify events +- [[file:delete-calendar-event.org][Delete Calendar Event]] - remove events diff --git a/claude-templates/.ai/workflows/retrospective.org b/claude-templates/.ai/workflows/retrospective.org new file mode 100644 index 0000000..3cf0494 --- /dev/null +++ b/claude-templates/.ai/workflows/retrospective.org @@ -0,0 +1,94 @@ +#+TITLE: Retrospective Workflow +#+DESCRIPTION: How to run a retrospective after major problem-solving sessions + +* When to Run a Retrospective + +Run after: +- Major debugging/troubleshooting sessions +- Complex multi-step implementations +- Any session where significant friction occurred +- Sessions lasting more than an hour with trial-and-error + +* 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" + +** 2. Answer These Questions (Both Parties) + +*** What went well? +Identify patterns worth reinforcing. Be specific. + +*** What didn't go well? +Identify friction points, mistakes, wasted time. No blame, just facts. + +*** What behavioral changes should we make? +Focus on *how we work*, not technical facts. +- Good: "Confirm before rebooting" +- Not behavioral: "AMD needs firmware 20260110" + +*** What would we do differently next time? +Specific scenarios and better approaches. + +*** Any new principles to add? +Distill lessons into short, actionable principles for retrospective/PRINCIPLES.org. + +** 3. Copy and Update retrospectives/PRINCIPLES.org + +Copy the template retrospectives/PRINCIPLES.org. + +Using the copied template, add new behavioral principles learned. Keep them: +- Short and actionable +- Focused on behavior, not facts +- Easy to remember and apply + +** 4. Create Retrospective Record + +Save to =.ai/retrospectives/YYYY-MM-DD-topic.org= with: +- Summary of what happened +- Answers to the questions above +- Link to detailed session doc if exists + +** 5. Commit Changes + +Commit PRINCIPLES.org updates and retrospective record. + +* PRINCIPLES.org Structure + +#+BEGIN_SRC org +,* How We Work Together +,** Principle Name +- Bullet points explaining the principle +- When it applies +- Why it matters + +,* Checklists +,** Checklist Name +- [ ] Step 1 +- [ ] Step 2 +#+END_SRC + +* Integration with Session Startup + +Add to project's protocols.org or session startup: +- Check if PRINCIPLES.org was updated since last session +- Review any new principles before starting work + +* Example Principles (Starters) + +** Sync Before Action +- Confirm before destructive or irreversible actions +- State what you're about to do and wait for go-ahead + +** Verify Assumptions +- When something "should work" but doesn't, question the assumption +- Test one variable at a time + +** Clean Up After Yourself +- Reset temporary changes before finishing +- Verify system is in expected state diff --git a/claude-templates/.ai/workflows/send-email.org b/claude-templates/.ai/workflows/send-email.org new file mode 100644 index 0000000..cfd7adf --- /dev/null +++ b/claude-templates/.ai/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/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org new file mode 100644 index 0000000..19045d3 --- /dev/null +++ b/claude-templates/.ai/workflows/startup.org @@ -0,0 +1,178 @@ +#+TITLE: Startup Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-04-25 + +* 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 is structured into four phases. *Phase A.0* is a sequential pre-flight; *Phase A and Phase B should each run as a single batch of parallel tool calls* — sending one message with multiple Bash / Read calls in it, not sequential round-trips. Phase C is interactive and runs sequentially. + +* The Workflow + +** Phase A.0 — Pre-flight: refresh claude-templates and project repo (sequential, runs first) + +Two refreshes happen before Phase A. Both are sequential pre-steps, not part of Phase A's parallel batch. Run them as two separate Bash calls in order. + +*** Refresh claude-templates + +Phase A's rsync commands copy from =~/projects/claude-templates/= into the project's =.ai/= directory. If that source repo is behind its own =origin/main=, the rsync silently reverts committed template updates in the project, dirtying the working tree. Pull claude-templates first so the rsync runs against current content. + +#+begin_src bash +ct="$HOME/projects/claude-templates" +if [ -d "$ct/.git" ]; then + if (cd "$ct" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then + (cd "$ct" && git pull --ff-only origin main 2>&1) | tail -3 + else + echo "claude-templates: dirty working tree — using as-is, skipping pull" + fi +else + echo "claude-templates: not a git checkout — skipping" +fi +#+end_src + +Behavior: +- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. +- *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start). +- *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is. + +*** Refresh project repo (cwd) + +Pull down whatever's been pushed to the project's remotes since the last session — could be commits Craig made on another machine, teammate pushes, or any branch that advanced upstream. Without this, the session starts from a stale local view and any new branch work happens on top of an out-of-date base. + +#+begin_src bash +if [ -d .git ]; then + git fetch --all --prune 2>&1 | tail -5 + + current=$(git symbolic-ref --short HEAD 2>/dev/null) + dirty=0 + if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \ + || [ -n "$(git status --porcelain --untracked-files=no)" ]; then + dirty=1 + fi + + git for-each-ref --format='%(refname:short)' refs/heads/ | while read branch; do + upstream=$(git rev-parse --abbrev-ref "${branch}@{upstream}" 2>/dev/null) || continue + counts=$(git rev-list --left-right --count "${upstream}...${branch}" 2>/dev/null) || continue + behind=$(echo "$counts" | cut -f1) + ahead=$(echo "$counts" | cut -f2) + + if [ "$behind" -gt 0 ] && [ "$ahead" -eq 0 ]; then + if [ "$branch" = "$current" ]; then + if [ "$dirty" -eq 0 ]; then + git merge --ff-only "$upstream" >/dev/null 2>&1 \ + && echo " $branch: fast-forwarded $behind commits" + else + echo " $branch: behind $behind — dirty tree, fetched only" + fi + else + git fetch . "${upstream}:${branch}" >/dev/null 2>&1 \ + && echo " $branch: fast-forwarded $behind commits (non-checkout)" + fi + elif [ "$ahead" -gt 0 ] && [ "$behind" -gt 0 ]; then + echo " $branch: diverged ($ahead ahead, $behind behind) — leaving alone" + fi + done +else + echo "project repo: not a git checkout — skipping" +fi +#+end_src + +Behavior, per branch: +- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD. +- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state. +- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree. +- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge. +- *Ahead only* or *up to date* → silent no-op. + +Phase A's rsyncs depend on the claude-templates refresh completing first. The project-repo refresh has no such dependency, but lives here for symmetry with the wrap-up's "push all local branches" step. + +** Phase A — Initial fan-out (one parallel batch) + +These calls have no dependencies on each other. Issue them all together in one message: + +1. =date "+%A %Y-%m-%d %H:%M %Z"= — accurate timestamp. +2. Check whether =.ai/session-context.org= exists (e.g. =[ -e .ai/session-context.org ] && echo present || echo absent=). +3. =rsync -a ~/projects/claude-templates/.ai/protocols.org .ai/protocols.org=. +4. =rsync -a --delete ~/projects/claude-templates/.ai/workflows/ .ai/workflows/=. +5. =rsync -a --delete ~/projects/claude-templates/.ai/scripts/ .ai/scripts/=. +6. =\ls -t .ai/sessions/ 2>/dev/null | head -5= — list 5 most recent session files. The backslash bypasses any =ls= alias in the user's profile. Without it, bare =ls -t= silently returns no output under =exa= (a common =ls= replacement) — which makes a sessions directory full of files look empty, and the agent then skips Phase B step 2. +7. =\ls -la inbox/ 2>/dev/null= — inventory the inbox. Same reason for the backslash escape, applied uniformly across the Phase A =ls= calls. +8. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C. +9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File). +10. Read =.ai/project-workflows/startup-extras.org= if it exists. + +Notes on the rsync commands: +- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside. +- =--delete= on the directory syncs lets retired template files actually disappear from each project on next startup. +- protocols.org is a single file, no =--delete= needed. + +Rationale: Every call in Phase A is read-only or writes to a distinct path. Running them sequentially wastes round-trips; running them in parallel gives Claude the complete starting picture in one round-trip. + +** Phase B — Dependent fan-out (one parallel batch) + +These calls depend on Phase A outputs, but are independent of each other. Issue them as a single parallel batch once Phase A returns: + +1. *Read =.ai/session-context.org= if Phase A reported it exists.* The file is the crash-recovery anchor — if it's there, the previous session was interrupted and the context lives only in this file. +2. *Read each of the 5 most recent session files* from Phase A's =\ls -t .ai/sessions/= output. Read just the =* Summary= section of each — not the full file. The Summary gives Active Goal / Decisions / Data Collected / Findings / Files Modified / Next Steps. That's enough to pick up where things left off. Drill into a specific =* Session Log= later only if you need the /why/ or sequence on something. *If Phase A's listing came back empty, sanity-check with =\ls -la .ai/sessions/= before treating empty as definitive — sessions/ should normally be populated, and an empty result usually means the listing got swallowed somewhere, not that the directory is genuinely empty.* +3. *Read each new inbox file* from Phase A's =\ls -la inbox/= output. For =.eml= files, defer to Phase C — those need the extract script (below) rather than a raw Read. +4. *Process pending cross-agent messages.* For each project with a pending count >0 in Phase A's =cross-agent-status= output (typically the current project; cross-project pending is surfaced too but only acted on if the user asks), run =cross-agent-recv <message-file>= on the file path =cross-agent-status= named. The script returns a structured decision (=process= / =dedup= / =query= / =reject=) per the protocol. For =process=, read the message body to determine the action. For =query=, prepare a clarifying reply. For =reject=, surface to user with the reason. For =dedup=, no action — silent retry already handled. Surface all decisions in Phase C alongside other findings. + +Rationale: Reads are independent and benign. Batching them means the whole session-history view + inbox view lands in one round-trip instead of one per file. + +** Phase C — Synthesis + interactive + +This phase touches the user and runs sequentially: + +1. *Surface findings from Phase A and B:* + - If =session-context.org= existed, summarize what was in flight at the crash point and ask whether to resume. + - Surface Active Reminders from notes.org immediately. + - Mention Pending Decisions from notes.org. + - Briefly note significant template updates noticed during sync (new workflows, protocol changes). + - *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it. +2. *Process inbox if non-empty.* Mandatory — don't ask, just do it. For each file: determine action, recommend filing, get approval, move. For =.eml= files use the extract script (not raw Read): + #+begin_src bash + # View mode — print metadata and body, extract attachments alongside EML + python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/message.eml + + # Pipeline mode — extract, auto-rename, refile to output dir, clean up + python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/message.eml --output-dir assets/target-dir/ + #+end_src + The script handles metadata extraction, HTML-to-text conversion, attachment extraction, and auto-renaming to =YYYY-MM-DD-HHMM-Sender-TYPE-Description.ext=. See [[file:../scripts/eml-view-and-extract-attachments-readme.org][EML script readme]]. +3. *Execute project-specific startup extras* (the contents of =.ai/project-workflows/startup-extras.org= read in Phase A). If the file didn't exist, skip. +4. *Ask about priorities.* "What would you like to work on, or is there something urgent you need?" + - If urgent: proceed immediately. + - If not: surface the top 3 priority A or B tasks in todo.org plus recent work as context. + +Rationale: User-facing work and decisions can't be parallelized — they have to happen one at a time so the user can react. + +* Workflow discovery (on demand, not at startup) + +Two directories hold workflows: +- =.ai/workflows/= — template workflows (synced from claude-templates, never edit in project). +- =.ai/project-workflows/= — project-specific workflows (never touched by sync). + +When the user says "let's run/do the [X] workflow" (or otherwise references a workflow by topic): + +1. *Read =.ai/workflows/INDEX.org=.* It maps trigger phrases to workflow filenames so you don't have to read each workflow's "When to Use" section to route. Project-workflows aren't in the index — handle those via =ls= in step 2. +2. *List both directories:* =ls -1 .ai/workflows/ .ai/project-workflows/ 2>/dev/null=. +3. *Drift check.* Compare the =.org= files in =.ai/workflows/= against the entries in INDEX.org (excluding INDEX.org itself). If an index entry points at a deleted file, or a file in the directory has no index entry, surface the mismatch to the user and offer to update INDEX.org. Don't silently route around it. +4. *Match the request* against the index trigger phrases first, then against project-workflow filenames if no index hit. +5. *Read and execute* the matching file. +6. *No match* → offer to create via =create-workflow=; new workflows go to =.ai/project-workflows/= and project-specific ones don't go in INDEX.org. +7. *Project extension.* As one of the very last steps in the matched workflow's flow, check if =.ai/project-workflows/= contains a file with the same name as the template that just ran. If yes, read and execute it as *additional* steps appended to the workflow — not a replacement. The project file contains add-on steps that pick up where the template's main flow ends. Surface the extension once per session ("Project has additional steps for send-email.org — running them now"). Only applies when the matched workflow came from =.ai/workflows/=; project-only workflows have no template to extend. This mirrors the startup-extras.org pattern: projects extend template behavior without forking the template repo. + +The index is the catalog; the directory is the truth. Drift between them is a bug — catching it on demand keeps the index honest without paying the read cost on every session. + +* Common Mistakes + +1. *Running Phase A sequentially.* Send all Phase A calls in one message — sequential rsync + ls + read costs round-trips for nothing. +2. *Reading the entire notes.org file* — only Project-Specific Context, Active Reminders, Pending Decisions. +3. *Skipping template sync* — projects fall behind on rule changes. +4. *Skipping Phase A.0* — rsync runs against a stale claude-templates checkout and silently reverts committed template updates in the project's =.ai/=, dirtying the working tree at session start. +5. *Auto-stashing or auto-merging in Phase A.0* — don't. If claude-templates has uncommitted edits or a non-fast-forward history, leave it alone and let the rsync run against the working tree as-is. +6. *Not checking for session-context.org* — lose context from crashed sessions. +7. *Forgetting to surface Active Reminders* — Craig misses critical items. +8. *Asking if Craig wants inbox processed* — it's mandatory, not optional. +9. *Announcing "session start complete"* — just begin working on the chosen task. +10. *Reading full session files instead of just the Summary section* — wastes context for past noise that lives in the Session Log. diff --git a/claude-templates/.ai/workflows/status-check.org b/claude-templates/.ai/workflows/status-check.org new file mode 100644 index 0000000..efff16d --- /dev/null +++ b/claude-templates/.ai/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/claude-templates/.ai/workflows/summarize-emails.org b/claude-templates/.ai/workflows/summarize-emails.org new file mode 100644 index 0000000..6ac5e6f --- /dev/null +++ b/claude-templates/.ai/workflows/summarize-emails.org @@ -0,0 +1,243 @@ +#+TITLE: Summarize Emails Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-02-14 + +* Overview + +This workflow filters out marketing noise and surfaces only emails that matter — messages from real people, businesses Craig works with, or anything that needs his attention. It chains together existing tools: sync-email, mu queries, the extract script, and Claude's judgment to produce a curated summary. + +* Problem We're Solving + +Craig's inbox contains a mix of important correspondence and marketing noise. Manually scanning through emails to find what matters wastes time and risks missing something important buried under promotional messages. This workflow automates the filtering and presents a concise summary of only the emails that deserve attention. + +* Exit Criteria + +Summary is complete when: +1. All qualifying emails in the requested scope have been reviewed +2. Summary presented to Craig, grouped by account +3. Temp directory cleaned up +4. Session context file updated + +* When to Use This Workflow + +When Craig says: +- "summarize my emails", "what emails do I have", "anything important in my inbox", "email summary" +- "any unread emails", "check my unread" +- "any starred emails", "show flagged emails" +- "emails from [person]", "what has [person] sent me" +- "emails also sent to Christine" + +* The Workflow + +** Step 1: Context Hygiene + +Before starting, write out the session context file and ask Craig if he wants to compact first. This workflow is token-heavy (reading multiple full emails). If the context window compresses mid-workflow, we may lose important details. Writing out session context prevents this data loss. + +** Step 2: Parse Scope + +Determine the mu query from Craig's request. Supported scope types: + +| Scope Type | Example Request | mu Query | +|--------------------+--------------------------------+---------------------------------------| +| Date range | "last week", "since Monday" | =date:1w..now=, =date:2026-02-10..now= | +| Unread only | "unread emails" | =flag:unread= | +| Unread + date | "unread emails this week" | =flag:unread date:1w..now= | +| Starred/flagged | "starred emails" | =flag:flagged= (with optional date) | +| From a sender | "emails from Dan" | =from:dan= (with optional =--maxnum=) | +| Sent to someone | "emails also sent to Christine"| =to:christine OR cc:christine= | + +Scopes can be combined. For example, "unread emails from Dan this week" becomes =flag:unread from:dan date:1w..now=. + +If no scope is provided or it's ambiguous, *ask Craig* before querying. + +** Step 3: Offer to Sync + +Ask Craig if he wants to sync first (=mbsync -a && mu index=). Don't auto-sync. If Craig confirms, run the [[file:sync-email.org][sync-email workflow]]. + +** Step 4: Query mu + +Append =NOT flag:list= to the query to exclude emails with List-* headers (catches most mailing list / marketing / bulk mail). + +#+begin_src bash +mu find --sortfield=date --reverse --fields="d f t s l" [query] NOT flag:list +#+end_src + +Output fields: date, from, to, subject, path. Sorted by date, newest first. + +** Step 5: Copy Qualifying Emails to Temp Directory + +Create an isolated temp directory for the summary work: + +#+begin_src bash +mkdir -p ./tmp/email-summary-YYYY-MM-DD/ +#+end_src + +Copy the EML files from their maildir paths into this directory. + +*CRITICAL: Copy FROM =~/.mail/=, never modify =~/.mail/=.* + +#+begin_src bash +cp ~/.mail/gmail/INBOX/cur/message.eml ./tmp/email-summary-YYYY-MM-DD/ +#+end_src + +** Steps 6 & 7: Header Inspection + Address Verification (one parallel batch) + +For each copied email, run BOTH the second-pass header inspection (Step 6 criteria below) AND the addressed-to-Craig check (Step 7 criteria below) as a single parallel batch — one Read per email per check, all issued together in one message. The two checks are independent per-email and don't depend on each other's outcome. + +After the batch returns, discard any email that fails either check, then proceed to Step 8 with the survivors. + +*** Step 6 criteria — Second-Pass Header Inspection + +Check headers for additional marketing signals that mu's =flag:list= might miss. Discard emails that match any of: + +**** Bulk Sender Tools +=X-Mailer= or =X-Mailtool= containing: Mailchimp, ExactTarget, Salesforce, SendGrid, Constant Contact, Campaign Monitor, HubSpot, Marketo, Brevo, Klaviyo + +**** Bulk Precedence +=Precedence: bulk= or =Precedence: list= + +**** Bulk Sender Patterns +=X-PM-Message-Id= patterns typical of bulk senders + +**** Marketing From Addresses +From address matching: =noreply@=, =no-reply@=, =newsletter@=, =marketing@=, =promotions@= + +*** Step 7 criteria — Verify Addressed to Craig + +Check To/CC headers contain one of Craig's addresses: +- =craigmartinjennings@gmail.com= +- =c@cjennings.net= + +Discard BCC-only marketing blasts where Craig isn't in To/CC. + +** Step 8: Run Extract Script on Survivors + +Use =eml-view-and-extract-attachments.py= in stdout mode (no =--output-dir=) to read each email's content: + +#+begin_src bash +python3 .ai/scripts/eml-view-and-extract-attachments.py ./tmp/email-summary-YYYY-MM-DD/message.eml +#+end_src + +This prints headers and body text to stdout without creating any files in the project. + +** Step 9: Triage and Summarize + +For each email, apply judgment: +- *Clearly needs Craig's attention* → summarize it (who, what, any action needed) +- *Unsure whether important* → summarize it with a note about why it might matter +- *Clearly unimportant* (automated notifications, receipts for known purchases, etc.) → mention it briefly but don't summarize in detail + +** Step 10: Present Summary + +Group by account (gmail / cmail). For each email show: +- From, Subject, Date +- Brief summary of content and any action needed +- Flag anything time-sensitive + +Example output format: + +#+begin_example +** Gmail + +1. From: Dan Smith | Subject: Project update | Date: Feb 14 + Dan is asking about the timeline for the next milestone. Needs a reply. + +2. From: Dr. Lee's Office | Subject: Appointment confirmation | Date: Feb 13 + Appointment confirmed for Feb 20 at 2pm. No action needed. + +** cmail + +1. From: Christine | Subject: Weekend plans | Date: Feb 14 + Asking about Saturday dinner. Needs a reply. + +** Skipped (not important) +- Order confirmation from Amazon (Feb 13) +- GitHub notification: CI passed (Feb 14) +#+end_example + +** Step 11: Clean Up + +Remove the temp directory: + +#+begin_src bash +rm -rf ./tmp/email-summary-YYYY-MM-DD/ +#+end_src + +If =./tmp/= is now empty, remove it too. + +** Step 12: Post-Summary Actions + +After presenting the summary, ask Craig if he wants to: + +*** Star emails + +Star specific emails by passing their maildir paths: + +#+begin_src bash +python3 .ai/scripts/maildir-flag-manager.py star --reindex /path/to/message1 /path/to/message2 +#+end_src + +To also mark starred emails as read in one step: + +#+begin_src bash +python3 .ai/scripts/maildir-flag-manager.py star --mark-read --reindex /path/to/message1 +#+end_src + +*** Mark reviewed emails as read + +Mark all unread INBOX emails as read across both accounts: + +#+begin_src bash +python3 .ai/scripts/maildir-flag-manager.py mark-read --reindex +#+end_src + +Or mark specific emails as read: + +#+begin_src bash +python3 .ai/scripts/maildir-flag-manager.py mark-read --reindex /path/to/message1 /path/to/message2 +#+end_src + +Use =--dry-run= to preview what would change without modifying anything. + +The script uses atomic =os.rename()= directly on maildir files — the same mechanism mu4e uses. Flag changes are persisted to the filesystem so mbsync picks them up on the next sync. + +*** Delete emails (future) +=mu= supports =mu remove= to delete messages from the filesystem and database. Not yet integrated into this workflow — explore when ready. + +** Step 13: Context Hygiene (Completion) + +Write out session-context.org again after the summary is presented, capturing what was reviewed and any action items identified. + +* Principles + +- *=maildir-flag-manager.py= for flag changes* — use the script for mark-read and star operations; it uses atomic =os.rename()= on maildir files (same mechanism as mu4e) and mbsync syncs changes on next run +- *Ask before syncing* — don't auto-sync; Craig may have already synced or may not want to wait +- *Ask before querying* — if scope is ambiguous, clarify rather than guess +- *Filter aggressively, surface generously* — when in doubt about whether an email is marketing, filter it out; when in doubt about whether it's important, include it in the summary +- *One pass through the extract script* — don't re-read emails; read once and summarize +- *Stdout mode only* — use the extract script without =--output-dir= to avoid creating files in the project +- *Clean up always* — remove the temp directory even if errors occur partway through + +* Tools Reference + +| Tool | Purpose | +|-------------------------------------+--------------------------------------| +| mbsync / mu index | Sync and index mail | +| mu find | Query maildir for matching emails | +| eml-view-and-extract-attachments.py | Read email content (stdout mode) | +| maildir-flag-manager.py | Mark read, star (batch flag changes) | + +* Files Referenced + +| File | Purpose | +|------------------------------------------------+-------------------------| +| [[file:sync-email.org][.ai/workflows/sync-email.org]] | Sync step | +| [[file:find-email.org][.ai/workflows/find-email.org]] | mu query patterns | +| .ai/scripts/eml-view-and-extract-attachments.py | Extract script | +| .ai/scripts/maildir-flag-manager.py | Flag management script | +| =~/.mail/gmail/= | Gmail maildir (READ ONLY) | +| =~/.mail/cmail/= | cmail maildir (READ ONLY) | + +* Living Document + +Update this workflow as we discover new marketing patterns to filter, useful query combinations, or improvements to the summary format. diff --git a/claude-templates/.ai/workflows/sync-email.org b/claude-templates/.ai/workflows/sync-email.org new file mode 100644 index 0000000..52a7caf --- /dev/null +++ b/claude-templates/.ai/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/claude-templates/.ai/workflows/task-review.org b/claude-templates/.ai/workflows/task-review.org new file mode 100644 index 0000000..98d26e1 --- /dev/null +++ b/claude-templates/.ai/workflows/task-review.org @@ -0,0 +1,216 @@ +#+TITLE: Task Review Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-04-25 + +* Overview + +Unified workflow for reviewing open tasks. Two modes share the same data-gathering and reconciliation logic, then branch on output: + +- *List mode* — exhaustive, priority-grouped display of every open task. Use when Craig wants to see his full plate. +- *Next mode* — single-task recommendation via prioritization cascade. Use when Craig wants to pick what to do next without scanning the whole list. + +This workflow supersedes the previous standalone =open-tasks.org= (list mode) and =whats-next.org= (next mode), which duplicated the data-gathering and reconciliation logic. + +* When to Use This Workflow + +User says one of: + +- *List mode triggers:* "list open tasks", "show me all tasks", "what's on my plate", "task review" +- *Next mode triggers:* "what's next", "what should I work on next", "what should I work on", "I need a recommendation" +- *Ambiguous:* "what should I do today", "show me my tasks" — ask which mode + +* Phase A: Data Gathering (one parallel batch — both modes) + +Issue all source reads as a single batch of parallel tool calls. They're independent: + +1. Read =notes.org= — sections *Active Reminders* and *Pending Decisions* only. +2. Read each of the 2-3 most recent files in =.ai/sessions/= — only the *Next Steps* subsection under =* Summary= (those capture recent "next time, do X" items that may still be open). +3. Read =todo.org= — entries under the open-work header (=* $Project Open Work=, e.g. =* Homelab Open Work=). Skip the resolved header (=* $Project Resolved=). +4. Run =date= for an accurate timestamp (used to evaluate deadlines + flag suspected completions). + +The phases below work entirely from this in-memory snapshot. The original split-workflow versions issued these reads sequentially in different files; consolidating saves round-trips and prevents the two implementations drifting apart. + +* Phase B: Reconcile (both modes) + +Compare the open-task signal across sources. For each task in =notes.org= Active Reminders or in a recent session's Next Steps that does NOT have a corresponding =todo.org= entry: + +1. Create a new =** TODO= entry under the =* $Project Open Work= header. +2. Assign a priority based on context: =[#A]= if time-sensitive or blocking, =[#B]= if important, =[#C]= if low urgency. +3. Include: + - =:CREATED:= property with today's date. + - Brief description of what needs to be done. + - Why it matters (context from the reminder or session note). + - Recommended approach or next steps. +4. If a deadline exists, add a =DEADLINE:= line. + +*Do NOT remove the item from =notes.org= Active Reminders.* Reminders serve a different purpose (surfaced at session start). The =todo.org= entry is for tracking and prioritization. + +*Judgment call:* Not every reminder needs a =todo.org= entry. Skip: +- Pure informational notes (e.g. "rsyncshot running with 600s timeout"). +- Waiting-for items with no action Craig can take (e.g. "package arriving Feb 25"). +- Items already completed (handle in Phase C list mode). + +* Phase C: Mode-Specific Output + +** List Mode + +*** Step 1: Review for Suspected Completions + +Quickly scan all open tasks and check if any appear already done, based on: +- Recent session history mentioning completion. +- Context clues (e.g. "arriving Feb 7" and today's date is Feb 12). +- Work completed in previous sessions that wasn't marked done. + +Build a list of *suspected completions* — do NOT mark them done yet. These get confirmed with Craig in Step 3. + +*** Step 2: Display All Open Tasks + +Present grouped by priority. Format rules: + +- *Group by priority:* A (High), B (Medium), C (Low/Someday). +- *Default priority:* Tasks without an explicit priority are treated as C. +- *No table structure* — use a flat bulleted list within each group. +- *Include deadlines:* If a task has a =DEADLINE:=, show it inline as =DEADLINE: <date>=. +- *Include scheduled dates:* If a task has a =SCHEDULED:=, show it inline. +- *Keep descriptions concise* — task name + one-line summary, not full details. +- *Note source* if task came from reminders only (not yet in =todo.org=) vs =todo.org=. + +Example: + +#+begin_example +**Priority A (High)** + +- Complete Sara Essex email setup — add Google Workspace MX records, verify delivery +- Set up Comet KVMs — remote console for TrueNAS and ratio +- Complete UPS/TrueNAS integration — USB cable, configure shutdown threshold. DEADLINE: <2026-01-21> + +**Priority B (Medium)** + +- Design Zettelkasten architecture — resume at Question 4 (Staleness) +- Compare Ubiquiti UTR vs open source mesh router + +**Priority C (Low / Someday)** + +- Explore Whisper-to-Claude-Code voice integration +- Get Keychron Q6 Pro carrying case. SCHEDULED: <2026-02-07> +#+end_example + +*** Step 3: Confirm Suspected Completions + +After displaying the list, present suspected completions: + +#+begin_example +These tasks may already be completed — can you confirm? +- "OBSBOT Tiny 3 webcam arriving" — it's past the expected delivery date +- "Sweetwater order arriving" — expected Feb 7, now Feb 12 +#+end_example + +For each task Craig confirms as done — applies to top-level (=**=) entries, which is what this workflow's list contains. Sub-tasks (=***+=) follow the dated-rewrite rule in [[file:../../claude-rules/todo-format.md][todo-format.md → Completion]] instead: +1. Add =CLOSED: [YYYY-MM-DD Day]= timestamp (use the =date= output from Phase A). +2. Change status from =TODO= to =DONE=. +3. Add a brief completion note (when/how it was resolved). +4. Move the entry from =* $Project Open Work= to =* $Project Resolved= in =todo.org=. +5. If the task also exists in Active Reminders in =notes.org=, remove it from there. + +For tasks Craig says are NOT done, leave them as-is. + +** Next Mode + +Apply the prioritization cascade in order. Stop at the first matching step: + +*** 1. In-Progress Tasks +- Look for tasks marked =DOING= or partially complete. +- *If found:* Recommend that task (always finish what's started). +- *If user declines:* Continue to next step. + +*** 2. Active Reminders +- Review notes.org Active Reminders (already in the Phase A snapshot). +- *If found:* Recommend reminder task. +- *If user declines:* Add to =todo.org= per Phase B (if not already there), then continue. + +*** 3. Deadline-Driven Tasks +- Scan =todo.org= for tasks with explicit deadlines. +- *If found:* Recommend the task with the closest deadline. +- *If none:* Continue to next step. + +*** 4. V2MOM Method Order (if applicable) +If =todo.org= is structured with V2MOM methods: +- Method 1 priority A tasks first. +- Then Method 2 priority A, Method 3 priority A, etc. +- Then Method 1 priority B, Method 2 priority B, etc. +- Continue pattern through priorities C and D. + +*** 5. Simple Priority Order +If =todo.org= is a flat list: +- Evaluate all priority A tasks, pick most important. +- If no priority A, evaluate priority B tasks. +- Continue through priorities C and D. + +*** 6. All Tasks Complete +If no tasks remain: report "All done — no open tasks in =todo.org=." + +*** Handling Multiple Tasks at Same Level + +When multiple tasks share priority/method position, pick one based on: + +1. *Blocks other work* — dependencies matter. +2. *Recently discussed* — mentioned in recent conversation. +3. *Most foundational* — enables other tasks. +4. *If truly uncertain* — show 2-3 options and let Craig choose. + +*** Output Format + +Keep the recommendation concise but informative: + +#+begin_example +Next: Fix org-noter reliability (Method 1, Priority A, 8/18 complete) +Reason: Blocks daily reading/annotation workflow +#+end_example + +Include: +- Task name / description. +- One-line reasoning (which cascade step matched and why). +- Progress indicator (for V2MOM-structured todos). + +* Resolving a Task — Format Reference + +When moving a task to Resolved (list mode Step 3), it should look like this: + +#+begin_example +** DONE [#A] Set up Comet KVMs +CLOSED: [2026-02-12 Thu] +:PROPERTIES: +:CREATED: [2026-01-19 Mon] +:END: + +Comet KVMs set up for TrueNAS and ratio. Remote BIOS/console access working. + +*Resolution:* Completed during Feb 12 session. Both KVMs connected and tested. +#+end_example + +Key elements: +- =DONE= replaces =TODO=. +- =CLOSED:= line with completion date. +- Original =:PROPERTIES:= block preserved. +- Brief resolution note explaining when/how. + +* Common Mistakes + +1. *Running Phase A sequentially* — issue all reads in one parallel batch. +2. *Marking tasks done without confirmation* — always ask Craig first (list mode Step 3). +3. *Removing reminders from =notes.org= when adding to =todo.org=* — they serve different purposes. +4. *Creating =todo.org= entries for pure informational reminders* — use judgment in Phase B. +5. *Using a table for the task list* — Craig prefers flat bulleted lists in list mode. +6. *Skipping the cascade order in next mode* — the cascade exists to override subjective choice with objective criteria; respect it. +7. *Recommending more than one task in next mode* — be decisive. Only show 2-3 if truly uncertain after applying the same-level tie-breakers. +8. *Re-querying =todo.org= during Phase C* — the snapshot from Phase A is canonical; don't re-read. + +* Living Document + +Update this workflow as task management patterns evolve. If new task sources are added (external issue trackers, shared task lists), add them to Phase A. If the cascade ordering needs to change for a project, document the variant here. + +* Replaces + +This file replaces: +- =open-tasks.org= (list mode logic now lives in Phase C → List Mode) +- =whats-next.org= (next mode logic now lives in Phase C → Next Mode) diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org new file mode 100644 index 0000000..36f9530 --- /dev/null +++ b/claude-templates/.ai/workflows/triage-intake.org @@ -0,0 +1,123 @@ +#+TITLE: Triage-Intake Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-11 + +* Overview + +On-demand triage of all inbox sources — invoked any time the user wants a "what's moved?" snapshot. Scans every source for recent activity (the three mail accounts, Slack, Linear, open PRs, both calendars, recent =todo.org= edits), surfaces what's moved and what's actionable, runs the Linear Dev-Review sweep, and clears unread state at the end — *every* unread INBOX message across all three mail accounts, plus every Slack conversation it surfaced, marked read. Same source set as =daily-prep.org='s triage section, lighter scope: no =notes.org= reading, no inbox-file processing, no meeting prep, no time-blocking, no prep-doc generation. Projects that want this workflow called from =wrap-it-up.org= (or elsewhere) opt in via a =.ai/project-workflows/<name>.org= extension; the template workflows don't call triage-intake themselves. + +* When to Use + +When the user says: +- "do a triage intake" / "triage intake" / "triage intake now" +- "what's moved?" / "what's new?" / "anything new since X?" +- "check for movement" + +* The Workflow + +** Phase 1: Fan-out (one parallel batch) + +Issue all of these in a single parallel batch (mix of MCP and Bash calls). Each source is optional — skip with a note if unavailable in this project (no MCP configured, no Slack workspace, no GitHub-family remote, etc.). This phase *surfaces recent activity for the report*; it doesn't bound what Phase 3 marks read — Phase 3 runs its own full unread query. + +1. *DeepSat Gmail* (=craig.jennings@deepsat.com=) — recent messages via =mcp__google-docs-work__listMessages= with =maxResults ~25=. +2. *Personal Gmail* (=craigmartinjennings@gmail.com=) — recent messages via =mcp__google-docs-personal__listMessages= with =maxResults ~15=. +3. *cmail / Proton* (=c@cjennings.net=) — local Maildir at =~/.mail/cmail/=, indexed by =mu=. Mirrors the mu4e unread-cmail query bound to =C-; e c u= in =mail-config.el=: + #+begin_src bash + mu find 'maildir:/cmail/INBOX AND flag:unread AND NOT flag:trashed' \ + --sortfield=date --reverse \ + --fields='d f s' \ + | head -30 + #+end_src + Proton has no Gmail API, so cmail is surfaced through =mu= (the two Google accounts use the richer Gmail MCPs above). Phase 3 re-queries the full unread set across all three accounts rather than relying on this capped list. +4. *Slack (slack-deepsat)* — =mcp__slack-deepsat__conversations_unreads= (all channels + DMs). +5. *Linear* — =mcp__linear__list_issues= with =assignee: "me"=, limit 30, ordered by recently updated. Surface tickets touched recently (new comments, status changes, new assignments). +6. *GitHub PRs* — for each active repo in the project, list open PRs. For GitHub Enterprise, pass =--hostname=: + #+begin_src bash + gh api repos/<owner>/<repo>/pulls --hostname <ghe-host> \ + -q '.[] | "#\(.number) \(.state) \(.title) — by \(.user.login), updated \(.updated_at)"' + #+end_src + Active repos and the host come from =.ai/project-workflows/triage-intake.org= (per-project list); fall back to the project's current-repo origin if no list is configured. Skip if the project has no GitHub-family remote. +7. *Calendars* — DeepSat work + personal (no cmail-associated calendar). Two parallel reads, looking at the next ~7 days: + - =mcp__google-docs-work__listEvents= (or the unified =mcp__google-calendar__list-events= scoped to the DeepSat account). + - =mcp__google-docs-personal__listEvents= for personal. + Surface newly-added events the user may not have noticed yet — calendar invites that landed during the session, meetings someone else scheduled, time blocks added. Highlight conflicts with existing commitments where they matter. +8. *(Optional) =todo.org= recent edits* — =git log -5 --oneline -- todo.org= or =stat= the file's mtime, to catch in-flight edits the user made between sessions. + +** Phase 2: Synthesize + Linear Dev-Review sweep + +Group findings by source, one short line per item. For each actionable item, propose the next step (reply / move ticket / close out / etc.). Don't propose actions for noise. Distinguish: + +- *Movement* — something changed (reply landed, ticket moved, PR pushed). Includes the actionable subset. +- *Noise* — promos, notifications, automated emails. Cleared by the mark-as-read pass in Phase 3, not reported individually. + +*** Linear Dev-Review sweep + +For every ticket assigned to the user with status =Dev Review= (from the Phase 1 =list_issues= output), check whether its linked PR is merged. Use the =gitBranchName= field on each ticket to find the PR on the project's remote: + +#+begin_src bash +gh pr list --search "head:<gitBranchName>" --state all \ + --json number,state,headRefName,mergedAt,title +#+end_src + +Skip the PR-merge check if the project has no GitHub-family remote (a self-hosted Gitea or plain-SSH remote has no =gh= support) — in that case surface the Dev-Review tickets and ask the user to confirm merge status manually. + +If a Dev-Review ticket's PR is *merged*, propose a status move: + +- *Done* — chores, refactors, test-coverage backfills, dead-code removal, e2e-flake fixes, anything with no PM-visible behavior change. PR titles prefixed =chore:=, =test:=, =refactor:=, =docs:= almost always belong here. +- *PM Acceptance* — real behavior fixes or new features a PM (or end user) could verify by clicking through. PR titles prefixed =fix:=, =feat:= usually belong here unless the change is invisible to users. + +When in doubt, ask the user per ticket — don't auto-pick. After approval, move via =mcp__linear__save_issue= with the new state. Several can run in parallel. + +Skip the sweep entirely if the project doesn't use Linear (personal projects, the rulesets repo, etc.). + +** Phase 3: Mark-as-read (at the end — not as you go) + +Clear unread state. Default behavior unless the user explicitly says "leave this unread." + +*** Email — every unread INBOX message, all three accounts + +All three mail accounts are synced to local Maildirs and =mu=-indexed: =~/.mail/gmail/= (personal), =~/.mail/dmail/= (DeepSat), =~/.mail/cmail/= (Proton). Phase 1 surfaces the two Google accounts through the Gmail MCPs (richer thread/label data for the report) and cmail through =mu= (no Gmail API for Proton). Mark-as-read goes through the Maildirs *uniformly* — it's the one mechanism that cleanly expresses "all unread, not just what I surfaced." + +1. Query every unread INBOX message across the three accounts: + #+begin_src bash + mu find 'flag:unread AND NOT flag:trashed AND (maildir:/gmail/INBOX OR maildir:/dmail/INBOX OR maildir:/cmail/INBOX)' \ + --fields='p' + #+end_src +2. Pass *all* the returned paths to the flag manager in one call: + #+begin_src bash + ~/code/rulesets/.ai/scripts/maildir-flag-manager.py mark-read --reindex \ + <path-1> <path-2> ... + #+end_src + *Always pass explicit paths.* Never call =mark-read= with no positional args — the bare default is "mark every unread message across every configured INBOX maildir on the machine," which happens to be the same set here but becomes a footgun the moment a project's extension narrows the scope. =--reindex= re-runs =mu index= so the local index reflects the new flags. =--dry-run= previews without modifying. +3. Flag changes land on disk. *Always run =mbsync -a= at the end of a triage-intake* so the read flags propagate to the servers before the workflow closes — don't leave them queued for the next sync, and don't ask first (Craig's standing instruction, 2026-05-12). The "conflicting changes (N,M)" notices mbsync prints when both sides touched flags are normal — it resolves them. (This overrides the older "ask before syncing" posture inherited from =summarize-emails.org=.) +4. Trash obvious junk separately and *only with user approval* — never trash without an explicit go-ahead. + +*** Slack — every conversation surfaced + +=mcp__slack-deepsat__conversations_mark= for each conversation touched during the intake, advancing the read pointer to the latest message surfaced. Don't mark conversations the intake didn't surface. + +*** Skip + +Linear, GitHub PRs, and calendars — none has a clean "mark this read" concept that maps to inbox hygiene. + +** Phase 4: Report + +One concise summary back to the user: + +- *What moved (by source)* — one line per change worth knowing. +- *Actionable* — numbered, with the proposed next step per item. +- *Cleared* — total count of emails (across the three accounts) and Slack conversations marked read. +- *Pending decisions* — anything needing user input before action (draft replies awaiting approval, ambiguous routing, etc.). +- *Synced* — confirm =mbsync -a= ran and note any non-trivial output. + +* Principles + +- *Read-only by default.* Triage intake surfaces and asks; it doesn't reply, move tickets, or trash. Mark-as-read is the one default exception — it's hygiene, not a state change worth a gate — and it covers *all* unread INBOX mail, not just what got surfaced in the report. +- *Surface first, mark second.* Mark-as-read happens at the end of the workflow, not inline. If the user pivots ("never mind, leave them"), nothing's already been touched. +- *Same sources as daily-prep, lighter scope.* This workflow exists for mid-day or on-demand checks; daily-prep is the once-a-day full version (and runs its own triage section as part of its flow). Keep the two consistent when either changes; the planned end state is daily-prep delegating its triage section to this workflow. +- *Each source is optional.* If a project doesn't have one (no Slack workspace, no GitHub-family remote, no cmail), skip it with a one-line note and continue. +- *Per-project extension* via =.ai/project-workflows/triage-intake.org=: override the cmail / Maildir paths and the =maildir-flag-manager= command, add project-specific sources (extra Slack workspaces, additional mail accounts, JIRA, etc.), list the GitHub repos and host to check. + +* Living Document + +Update this workflow when source MCPs or local mail tooling change (new Slack workspace, switch from =mu= to =notmuch=, different cmail account, additional Maildir, etc.). diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org new file mode 100644 index 0000000..8a38a4d --- /dev/null +++ b/claude-templates/.ai/workflows/wrap-it-up.org @@ -0,0 +1,393 @@ +#+TITLE: Session Wrap-Up Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-04-20 + +* Overview + +This workflow defines the process for ending a Claude Code session cleanly. It finalizes the session record, commits + pushes all work, and provides a warm handoff. + +Triggered by Craig saying "wrap it up," "that's a wrap," "let's call it a wrap," or similar. + +* The Session Record + +Throughout the session, =.ai/session-context.org= has been maintained with: +- =* Summary= — structured distillation (empty or draft during session) +- =* Session Log= — chronological narrative of what happened, written as you go + +At wrap-up, this file becomes the permanent session record by being renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. No transcription elsewhere. The file IS the record. + +* Exit Criteria + +The wrap-up is complete when: + +1. *Summary is written.* The =* Summary= section of =.ai/session-context.org= is populated by reading the =* Session Log= — Active Goal, Decisions, Data Collected / Findings, Files Modified, Next Steps. +2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists. +3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any. +4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule. +5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean. +6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders. + +The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted. + +* The Workflow + +** Step 1: Finalize the Summary + +Read through the =* Session Log= in =.ai/session-context.org=. Populate (or refine) the =* Summary= section: + +- *Active Goal* — one or two sentences describing the session's focus +- *Decisions* — key choices made, with enough context to recall the /why/ +- *Data Collected / Findings* — anything concrete (measurements, root causes, paths, discoveries) +- *Files Modified* — what was changed, with one-line rationale per significant file +- *Next Steps* — what should happen in the next session + +Don't repeat everything from the Log in the Summary. The Summary is distillation — pull out what's load-bearing. The Log stays in the file and is available if a future reader wants detail. + +** Step 2: Pick a description + rename + +Read the Summary's Active Goal and the prominent entries in the Session Log. Pick a 4-6 word description that would make sense as a git-commit-message-series summary for the whole session. + +Good descriptions are concrete nouns/verbs: +- =docs-ai-migration-and-ai-launcher= +- =mybitch-usb-disconnect-diagnosis= +- =ratio-system-health-check= +- =orchestration-dashboard-bug-triage= + +Avoid vague ones: +- =session-work= (useless) +- =various-improvements= (useless) +- =updates= (useless) + +Get current time and rename: + +#+begin_src bash +mkdir -p .ai/sessions +now=$(date +%Y-%m-%d-%H-%M) +mv .ai/session-context.org .ai/sessions/${now}-DESCRIPTION.org +#+end_src + +Replace =DESCRIPTION= with your picked slug. + +** Step 3: todo.org cleanup (hygiene + archive completed work) + +If the project has a =todo.org= at its root, run the cleanup script before committing. Two passes, both fast and idempotent: a hygiene pass and an archive pass. + +*** Hygiene pass + +It catches a recurring pattern: org sometimes leaves noise lines like =- State "X" from "X" [date]= when a state-change log lands outside a =:LOGBOOK:= drawer and the state didn't actually change. These lines carry no information and they break org's planning-line parser by wedging between the heading and =DEADLINE:=/=SCHEDULED:=, which kicks the entry out of agenda views. + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el todo.org +#+end_src + +The script is fast (under half a second on a 4000-line file) and idempotent — if there's nothing to fix, it reports zero changes and exits clean. + +What it does: + +1. *Auto-deletes* bogus state-log lines (matched on identical from/to states). Any deletions show up in the wrap-up commit's diff, so they get reviewed before push. +2. *Reports* "orphan planning lines" — entries whose body has =DEADLINE:= or =SCHEDULED:= but =org-entry-get= can't read it (some other malformation kept it out of canonical position). The script doesn't auto-rewrite these because the right fix depends on whether real state-log history needs preserving — surface them and fix manually if they matter for the agenda. + +Run the report-only variant first if you want to see what would change without writing: + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org +#+end_src + +*** Archive completed work + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org +#+end_src + +=--archive-done= moves every level-2 subtree whose TODO state is DONE or CANCELLED out of the project's "Open Work" section and into its "Resolved" section, subtree intact. The two sections are matched by a unique level-1 heading containing "Open Work" (case-insensitive) and one containing "Resolved" — if either is missing or ambiguous, the file is skipped with a message, no crash. Only direct level-2 children move; a DONE entry nested under an open parent stays put. Idempotent; any moves show up in the wrap-up commit's diff for review before push. + +Preview the moves without writing: + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org +#+end_src + +*** Sync child priorities + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el --sync-child-priority todo.org +#+end_src + +=--sync-child-priority= walks every heading with a priority cookie =[#A]=–=[#D]= and, for each of its direct child headings whose own priority cookie is /lower/ (later in the alphabet — D is below A), bumps the child to match the parent. Down-only: parents are never bumped up to match a higher-priority child. Children without a priority cookie are left alone, as are parents without one. The walk visits parents before descendants, so a multi-level chain (=[#A]= → =[#B]= → =[#D]=) collapses to the top priority in a single pass. Idempotent. + +Opt-out for deliberately-lower children: tag the heading =:no-sync:= (the literal six-character tag, including the hyphen). The script matches the tag literally on the heading line, so it works whether or not the surrounding emacs config has extended =org-tag-re= to allow hyphens. + +#+begin_example +*** TODO [#D] Follow-up: VAD :no-sync: +#+end_example + +Use this for =Follow-up:=, =Spike:=, =Stretch:= sub-tasks that are deliberately deprioritized below their parent — without the tag, the wrap-up would silently bump them back up. + +Preview the bumps without writing: + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --check-child-priority todo.org +#+end_src + +(=--check-child-priority= is the report-only alias for =--sync-child-priority --check=.) + +*** Lint org files (mechanical sweep, judgments deferred) + +#+begin_src bash +followups="${LINT_ORG_FOLLOWUPS:-$HOME/projects/work/inbox/lint-followups.org}" +[ ! -d "$(dirname "$followups")" ] && followups=".ai/lint-followups.org" +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/lint-org.el \ + --followups-file="$followups" todo.org +#+end_src + +=lint-org= runs =org-lint= over =todo.org=, auto-applies four mechanical +categories (=item-number= counters, bare =#+begin_src= → =#+begin_example=, +multi-line planning-info merged onto one line, =**X.**= → =*X.*=), and +appends every remaining judgment item (broken file links, invalid fuzzy +links, verbatim-asterisk inside body prose, suspicious src-block languages) +to the follow-ups file as a dated org section. Mechanical fixes show up in +the wrap-up commit's diff for review before push. + +The follow-up path defaults to =~/projects/work/inbox/lint-followups.org= +(where the next morning's daily-prep merges it in). If that directory +doesn't exist on the machine, the script falls back to +=.ai/lint-followups.org= inside the current project. Override with +=LINT_ORG_FOLLOWUPS=<path>= in the environment if needed. + +Preview without writing — same flags as =--check= on the other scripts: + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/lint-org.el --check todo.org +#+end_src + +The wrap-up never blocks on judgment items — they're deferred by design. +For an interactive walk of the judgments mid-day, run =/lint-org todo.org=. + +*** Date-coverage scan (surface =[#A]= / =[#B]= tasks lacking a timestamp) + +Scan =todo.org= for open =[#A]= and =[#B]= tasks that have neither a =DEADLINE:= nor a =SCHEDULED:= line directly under the heading, and surface the candidates to the follow-ups file so the morning's daily-prep flags them for review. + +The two timestamps mean different things (=DEADLINE:= = external, consequence-bearing; =SCHEDULED:= = social, accountability-bearing — see the priority spec at the top of =todo.org=). High-priority work that carries neither is suspicious: either it has an implicit deadline that should be made explicit, or it has someone waiting that should surface in the agenda, or its priority is wrong. The scan flags candidates; the operator decides. + +#+begin_src bash +followups="${LINT_ORG_FOLLOWUPS:-$HOME/projects/work/inbox/lint-followups.org}" +[ ! -d "$(dirname "$followups")" ] && followups=".ai/lint-followups.org" +[ -f todo.org ] && awk ' + /^\*\* (TODO|DOING|VERIFY) \[#[AB]\]/ { + if (heading != "" && !has_date) print line ": " heading + heading = $0 + line = NR + has_date = 0 + next + } + /^(SCHEDULED|DEADLINE|CLOSED):/ { has_date = 1; next } + /^\*+ / { if (heading != "" && !has_date) print line ": " heading; heading = ""; next } + END { if (heading != "" && !has_date) print line ": " heading } +' todo.org > /tmp/date-coverage.out +if [ -s /tmp/date-coverage.out ]; then + { + printf "\n* %s — Date coverage: [#A] / [#B] tasks without DEADLINE or SCHEDULED\n" "$(date '+%Y-%m-%d %a')" + printf "%s\n" "Review each: add a date, drop the priority, or confirm 'no-date by intent' inline." + sed 's/^/- /' /tmp/date-coverage.out + } >> "$followups" +fi +rm -f /tmp/date-coverage.out +#+end_src + +The scan is intentionally conservative — it surfaces every candidate. False positives (tasks that legitimately have no date) are cheap to dismiss; false negatives would let high-priority work drift undated. No-date is a valid resting state for some tasks (research, watch-list), and the operator can mark those as such in the daily-prep review rather than tagging them in =todo.org=. + +** Step 3.5: Linear ticket-state hygiene (skip if project doesn't use Linear) + +If the project uses Linear and has any tickets currently in *Dev Review* assigned to Craig, sweep them before the wrap-up commit. The check is fast and keeps the board honest — tickets stuck in Dev Review after their PR merges hide actual work-in-progress. + +#+begin_src +mcp__linear__list_issues assignee="me" state="Dev Review" limit=50 +#+end_src + +For each result, look up the linked PR (the =gitBranchName= field on the issue maps to a =headRefName= on the project's GitHub remote — use =gh pr list --author <github-login> --state all --json number,state,headRefName,mergedAt,title=). If a Dev-Review ticket's PR is *merged*, propose a move: + +- *Done* — chores, refactors, test-coverage backfills, dead-code removal, e2e-flake fixes, anything with no PM-visible behavior change. PR titles prefixed =chore:=, =test:=, =refactor:=, =docs:= almost always belong here. +- *PM Acceptance* — real behavior fixes or new features a PM (or end user) could verify by clicking through the app. PR titles prefixed =fix:=, =feat:= usually belong here unless the change is invisible to users. + +When in doubt, ask Craig per ticket. Don't auto-pick. After Craig confirms, move via =mcp__linear__save_issue= with =state="Done"= or =state="PM Acceptance"=. Several can run in parallel. + +Skip the step entirely if the project doesn't use Linear (e.g. personal projects, the rulesets repo). + +** Step 4: Git commit + push + +*** Review changes + +#+begin_src bash +git status +git diff --stat +#+end_src + +Decide the scope of the wrap-up commit. Usually everything that changed during the session goes into one commit. If anything is intentionally not part of this session's work (pre-existing WIP, unrelated files), leave it out. + +*** Stage + +Add the renamed session file and all other session changes: + +#+begin_src bash +git add .ai/sessions/ [other modified paths] +#+end_src + +Do NOT blindly =git add .= — review what's being staged so unrelated dirty state isn't dragged in. + +*** Commit + +Commit message rules (also see protocols.org "Git Commit Requirements"): + +- Subject line: concise, describes what /shipped/. Use conventional prefixes (=docs:=, =refactor:=, =fix:=, =feat:=, =chore:=) — NEVER =session:=. +- Body: 1-3 terse sentences describing what was accomplished. +- NO Claude Code attribution. NO =Co-Authored-By=. NO references to =notes.org=, =session-context.org=, =.ai/sessions/=, "session wrap-up", or session timestamps. + +*Wrap-up commits skip the inline-approval gate.* The =commits.md= rule that requires writing the message to =/tmp/commit-<slug>.md=, printing inline, and waiting for an approve / request-changes / open-in-editor response does *not* apply to wrap-up commits. The wrap-up flow is meant to be quick — Craig has already authorized the wrap by triggering the workflow ("wrap it up"), and stopping again to approve a commit message disrupts the cadence. + +Still apply =/voice personal= silently before committing so the message reads cleanly. Just don't print and ask. Commit directly with the cleaned message. + +If a wrap-up commit needs Craig's eyes for a content reason (sensitive change, unusual scope, something he flagged earlier), surface it explicitly. Otherwise commit and move on. + +Example: +#+begin_example +docs: restructure docs/ to .ai/ and unify aix+hey into ai launcher + +Hidden .ai/ now holds Claude tooling; project-level docs/ reserved +for user-facing docs. Single 'ai' launcher (fzf multi + smart tmux ++ git-aware fetch/pull) replaces the aix script and hey alias. +#+end_example + +Use heredoc for multi-line: +#+begin_src bash +git commit -m "$(cat <<'EOF' +subject line here + +body sentences here. +EOF +)" +#+end_src + +*** Push to all remotes + +#+begin_src bash +git remote -v +#+end_src + +Push the current branch to every remote (preserves the mirror behavior — rulesets and a few other repos have github.com + cjennings.net mirrors that should both stay current): + +#+begin_src bash +current=$(git symbolic-ref --short HEAD) +for r in $(git remote); do git push "$r" "$current"; done +#+end_src + +Then push every other local branch with a tracking upstream to its tracking remote. This catches feature branches that advanced during the session but aren't the one being wrapped up — without it, work-in-progress branches stay local-only and are at risk if the machine dies before the next wrap-up. + +#+begin_src bash +git for-each-ref --format='%(refname:short) %(upstream:remotename)' refs/heads/ | \ +while read branch remote; do + [ "$branch" = "$current" ] && continue + if [ -z "$remote" ]; then + echo " $branch: no tracking upstream — skipped (push manually with 'git push -u')" + else + git push "$remote" "$branch" + fi +done +#+end_src + +Behavior: +- *Tracked branches* → pushed to their upstream remote. +- *Untracked branches* (no upstream set) → surfaced, not pushed. Craig sets the upstream manually with =git push -u <remote> <branch>= when he's ready. Auto-creating an upstream would commit to a remote choice the workflow can't make safely. +- *Diverged or rejected pushes* → surface and stop. Don't force-push from this workflow; resolve manually. + +*** Resolve every worktree leftover + +#+begin_src bash +git status --short +#+end_src + +*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist. + +This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty." + +**** Three kinds of leftover + +| Pattern | What it is | Recommended action (apply unless user defers) | +|---+---+---| +| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. | +| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. | +| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. | + +**** Per-file flow + +For each leftover line in =git status --short=: + +1. Identify which of the three kinds above it matches. +2. State what the file is (one line) and the recommended action. +3. Apply the action unless the user explicitly defers. +4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry). + +The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution. + +**** When the user defers + +If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again." + +** Step 5: Valediction + +Brief, warm closing. 3-4 sentences max. + +Include: +- What was accomplished (specific, not generic) +- What's ready for next session +- Any critical reminders or deadlines + +Tone: warm but professional. No emoji unless Craig has explicitly requested. Acknowledge effort when session was long or difficult. + +Example: +#+begin_example +That's a wrap. Today we restructured the entire claude-templates +ecosystem: docs/ → .ai/ across all 23 projects, unified aix + hey +into a single 'ai' launcher with git-aware fetch/pull, and cleaned +up 4 code projects on velox. Both machines fully in sync. + +Two things to pick up next: the chime README WIP (your inline notes +from earlier) and archsetup's layout-navigate tests. Both are +ratio-local uncommitted state. + +Good session. Talk tomorrow. +#+end_example + +* Common Mistakes to Avoid + +1. *Skipping Step 1 (Summary)* — the file becomes the record; an empty Summary makes it hard to scan at catch-up +2. *Vague description in filename* — =2026-04-20-updates.org= is useless next to =2026-04-20-13-45-docs-ai-migration.org= +3. *=git add .= without review* — drags in unrelated dirty state +4. *=session:= prefix in commit message* — explicitly forbidden; use real change categories +5. *Claude-tooling references in commit message* — describes tooling, not what shipped +6. *Forgetting to push to all remotes* — check =git remote -v=, push to each +7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup +8. *Long preachy valediction* — brief beats thorough +9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later. +10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction. + +* Validation Checklist + +Before considering wrap-up complete: + +- [ ] =.ai/session-context.org= =* Summary= section populated +- [ ] File renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= +- [ ] =.ai/session-context.org= no longer exists +- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root) +- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists) +- [ ] Any orphan-planning-line warnings reviewed (fix or accept) +- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear) +- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction +- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation +- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified +- [ ] Forgotten changes committed in a follow-up and pushed +- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction +- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=) +- [ ] All other local branches with a tracking upstream pushed to their remote +- [ ] Any untracked-upstream branches surfaced for manual =git push -u= +- [ ] Commit message follows format (no =session:=, no Claude attribution) +- [ ] Valediction delivered (brief, specific, warm) diff --git a/claude-templates/.gitignore b/claude-templates/.gitignore new file mode 100644 index 0000000..75c6182 --- /dev/null +++ b/claude-templates/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/claude-templates/Makefile b/claude-templates/Makefile new file mode 100644 index 0000000..e097cc2 --- /dev/null +++ b/claude-templates/Makefile @@ -0,0 +1,39 @@ +.DEFAULT_GOAL := help + +PREFIX ?= $(HOME)/.local/bin +SRC := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + +.PHONY: help install uninstall list test-scripts + +help: + @printf 'claude-templates — session launcher + project seed\n\n' + @printf 'Targets:\n' + @printf ' make install symlink bin/ai into %s\n' "$(PREFIX)" + @printf ' make uninstall remove the symlink\n' + @printf ' make list show installed symlink\n' + @printf ' make test-scripts run pytest + ERT suites under .ai/scripts/tests/\n' + +install: + @mkdir -p $(PREFIX) + @chmod +x $(SRC)/bin/ai + @ln -sfn $(SRC)/bin/ai $(PREFIX)/ai + @printf 'installed %s/ai -> %s/bin/ai\n' "$(PREFIX)" "$(SRC)" + +uninstall: + @if [ -L $(PREFIX)/ai ]; then \ + rm $(PREFIX)/ai; \ + printf 'removed %s/ai\n' "$(PREFIX)"; \ + else \ + printf '%s/ai is not a symlink; nothing to remove\n' "$(PREFIX)"; \ + fi + +list: + @ls -la $(PREFIX)/ai 2>/dev/null || printf 'not installed\n' + +test-scripts: + @cd $(SRC)/.ai/scripts/tests && python -m pytest + @set -e; for f in $(SRC)/.ai/scripts/tests/test-*.el; do \ + [ -e "$$f" ] || continue; \ + echo "ert: $$(basename "$$f")"; \ + emacs --batch -q -L $(SRC)/.ai/scripts -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \ + done diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai new file mode 100755 index 0000000..45cc56a --- /dev/null +++ b/claude-templates/bin/ai @@ -0,0 +1,400 @@ +#!/bin/bash +# ai — Claude Code session launcher (unified aix + hey) +# +# Usage: +# ai Select one or more projects via fzf and open each in +# an 'ai' tmux session window (creates session if needed). +# Git-aware: fetches, annotates with ↑/↓/dirty, auto-pulls +# clean-and-behind repos before opening. +# +# ai <dir>... Single-project mode. Opens each given directory directly +# in the 'ai' session (new window or switch to existing). +# Use '.' for current directory. Git prep per dir. +# +# ai --attach Attach to the existing 'ai' session without changes. +# +# ai -h | --help Show this help. +# +# Source: ~/projects/claude-templates/bin/ai +# Install: make -C ~/projects/claude-templates install +# +# NOTE: do not enable `set -e`. Several helper functions use +# `[ test ] && action` patterns that legitimately return non-zero when +# the test fails (e.g. maybe_add_candidate when a dir lacks .ai/protocols.org, +# git_status_indicator when a repo is clean). With set -e those exit codes +# would kill the script. + +SESSION="ai" +CLAUDE_CMD="claude" +CLAUDE_INSTRUCTIONS='Read .ai/protocols.org and follow all instructions.' + +usage() { + sed -n '2,20p' "$0" | sed 's|^# \?||' + exit 0 +} + +for cmd in fzf tmux claude; do + if ! command -v "$cmd" &>/dev/null; then + echo "ai: $cmd is not installed" >&2 + exit 1 + fi +done + +# ---------- shared helpers ---------- + +attach_session() { + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$SESSION" + else + tmux attach-session -t "$SESSION" + fi +} + +# Create a window in the ai session, launch claude, return window id. +create_window() { + local dir="$1" name="$2" wid + wid=$(tmux new-window -t "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') + sleep 0.1 + tmux send-keys -t "$wid" "$CLAUDE_CMD \"$CLAUDE_INSTRUCTIONS\"" Enter + echo "$wid" +} + +# Add a directory to candidates only if it's a Claude-template project. +maybe_add_candidate() { + local dir="$1" + [ -f "$dir/.ai/protocols.org" ] && candidates+=("~/${dir#"$HOME"/}") +} + +# Build un-annotated candidate list (used by multi_mode and sort_windows). +build_candidates() { + candidates=() + maybe_add_candidate "$HOME/.emacs.d" + if [ -d "$HOME/code" ]; then + while IFS= read -r d; do + maybe_add_candidate "$d" + done < <(find "$HOME/code" -maxdepth 1 -mindepth 1 -type d | sort) + fi + if [ -d "$HOME/projects" ]; then + while IFS= read -r d; do + maybe_add_candidate "$d" + done < <(find "$HOME/projects" -maxdepth 1 -mindepth 1 -type d | sort) + fi +} + +# Parallel fetch across all current candidates (capped concurrency). +fetch_candidates() { + local max=6 running=0 dir + for c in "${candidates[@]}"; do + dir="${c/#\~/$HOME}" + [ -d "$dir/.git" ] || continue + git -C "$dir" fetch --quiet 2>/dev/null & + running=$((running + 1)) + if [ "$running" -ge "$max" ]; then + wait + running=0 + fi + done + wait +} + +# Return " (↑N ↓N dirty)" or " (✓)" if clean. +git_status_indicator() { + local dir="$1" upstream ahead=0 behind=0 parts=() + [ -d "$dir/.git" ] || return 0 + + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + if [ -n "$upstream" ]; then + ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) + behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) + [ "${ahead:-0}" -gt 0 ] 2>/dev/null && parts+=("↑$ahead") + [ "${behind:-0}" -gt 0 ] 2>/dev/null && parts+=("↓$behind") + else + parts+=("no upstream") + fi + + if ! git -C "$dir" diff --quiet 2>/dev/null \ + || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ + || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then + parts+=("dirty") + fi + + if [ ${#parts[@]} -gt 0 ]; then + local IFS=' ' + printf ' (%s)' "${parts[*]}" + else + printf ' (✓)' + fi +} + +# Append status annotations to every candidate. +annotate_candidates() { + local dir status annotated=() + for c in "${candidates[@]}"; do + dir="${c/#\~/$HOME}" + status=$(git_status_indicator "$dir") + annotated+=("${c}${status}") + done + candidates=("${annotated[@]}") +} + +# Pull if clean, behind, not ahead. No-op otherwise. +auto_pull_if_clean() { + local dir="$1" upstream ahead behind + [ -d "$dir/.git" ] || return 0 + + if ! git -C "$dir" diff --quiet 2>/dev/null \ + || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ + || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then + return 0 + fi + + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + [ -z "$upstream" ] && return 0 + + ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) + [ "${ahead:-0}" -gt 0 ] 2>/dev/null && return 0 + + behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) + [ "${behind:-0}" -eq 0 ] 2>/dev/null && return 0 + + git -C "$dir" pull --ff-only --quiet 2>/dev/null || true +} + +# Strip " (annotation)" suffix from fzf output so downstream gets raw paths. +read_selections() { + selected=() + while IFS= read -r line; do + selected+=("${line%% (*}") + done <<<"$1" +} + +# Re-order windows: non-project windows at base-index, projects alphabetically after. +sort_windows() { + local windows others="" projects="" base_idx project_names="" + base_idx=$(tmux show-option -gv base-index 2>/dev/null || echo 0) + windows=$(tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}') + + build_candidates + for c in "${candidates[@]}"; do + project_names+="$(basename "${c/#\~/$HOME}")"$'\n' + done + + while IFS=$'\t' read -r wname wid; do + [ -z "$wname" ] && continue + if echo "$project_names" | grep -qxF "$wname"; then + projects+="${wname}"$'\t'"${wid}"$'\n' + else + others+="${wname}"$'\t'"${wid}"$'\n' + fi + done <<<"$windows" + others=$(echo -n "$others" | sort -t$'\t' -k1,1f) + projects=$(echo -n "$projects" | sort -t$'\t' -k1,1f) + + local all + all=$(printf '%s\n' "$others" "$projects" | sed '/^$/d') + + local i=900 + while IFS=$'\t' read -r _n wid; do + tmux move-window -s "$wid" -t "$SESSION:$i" + i=$((i + 1)) + done <<<"$all" + + i=$base_idx + if [ -n "$others" ]; then + while IFS=$'\t' read -r _n wid; do + tmux move-window -s "$wid" -t "$SESSION:$i" + i=$((i + 1)) + done <<<"$others" + fi + if [ -n "$projects" ]; then + while IFS=$'\t' read -r _n wid; do + tmux move-window -s "$wid" -t "$SESSION:$i" + i=$((i + 1)) + done <<<"$projects" + fi +} + +# Find existing window id in ai session by window name; empty if none. +find_window_id() { + local name="$1" + tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null \ + | awk -F'\t' -v n="$name" '$1 == n {print $2; exit}' +} + +# Git prep for a single directory. Uses FETCH_HEAD cache to skip back-to-back +# fetches. Pulls automatically if clean-and-behind; prints one-line summary +# if diverged/dirty/ahead. +prep_git_single() { + local dir="$1" gitdir upstream ahead=0 behind=0 dirty="" age fetch_stale=1 parts=() + git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + + gitdir=$(git -C "$dir" rev-parse --git-dir 2>/dev/null) + if [ -f "$gitdir/FETCH_HEAD" ]; then + age=$(( $(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0) )) + [ "$age" -lt 600 ] && fetch_stale=0 + fi + [ "$fetch_stale" -eq 1 ] && git -C "$dir" fetch --quiet 2>/dev/null || true + + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + [ -z "$upstream" ] && return 0 + + ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) + behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) + + if ! git -C "$dir" diff --quiet 2>/dev/null \ + || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ + || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then + dirty="dirty" + fi + + if [ -z "$dirty" ] && [ "${ahead:-0}" -eq 0 ] && [ "${behind:-0}" -gt 0 ]; then + echo "ai: pulling $behind commit(s) from $upstream..." >&2 + git -C "$dir" pull --ff-only --quiet + elif [ "${ahead:-0}" -gt 0 ] || [ "${behind:-0}" -gt 0 ] || [ -n "$dirty" ]; then + [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead") + [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind") + [ -n "$dirty" ] && parts+=("$dirty") + echo "ai: $(basename "$dir") — ${parts[*]}" >&2 + fi +} + +# ---------- modes ---------- + +attach_mode() { + if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "ai: no '$SESSION' session to attach to" >&2 + exit 1 + fi + sort_windows + attach_session +} + +# Open a single project (or focus existing window). +single_mode() { + local arg="$1" dir name wid existing + dir="$(cd "$arg" 2>/dev/null && pwd)" || { echo "ai: cannot access '$arg'" >&2; return 1; } + + if [ ! -f "$dir/.ai/protocols.org" ]; then + echo "ai: $dir has no .ai/protocols.org — not a Claude-template project" >&2 + return 1 + fi + + name="$(basename "$dir")" + + # Window already exists? Focus it, skip git prep (claude is running in there). + if tmux has-session -t "$SESSION" 2>/dev/null; then + existing=$(find_window_id "$name") + if [ -n "$existing" ]; then + tmux select-window -t "$existing" + attach_session + return 0 + fi + fi + + prep_git_single "$dir" + + # Create session with first window, or add a new window. + if tmux has-session -t "$SESSION" 2>/dev/null; then + wid=$(create_window "$dir" "$name") + else + wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') + tmux send-keys -t "$wid" "$CLAUDE_CMD \"$CLAUDE_INSTRUCTIONS\"" Enter + fi + + sort_windows + tmux select-window -t "$wid" + attach_session +} + +# Multi-select via fzf (the original aix flow). +multi_mode() { + local filtered=() selections first_wid="" + + if tmux has-session -t "$SESSION" 2>/dev/null; then + # Add to existing session — filter out projects already open. + local existing + existing=$(tmux list-windows -t "$SESSION" -F '#{window_name}') + build_candidates + for c in "${candidates[@]}"; do + local n + n="$(basename "${c/#\~/$HOME}")" + if ! echo "$existing" | grep -qxF "$n"; then + filtered+=("$c") + fi + done + if [ ${#filtered[@]} -eq 0 ]; then + echo "All projects already have windows open." + sort_windows + attach_session + return 0 + fi + candidates=("${filtered[@]}") + else + build_candidates + fi + + echo "Fetching remotes..." >&2 + fetch_candidates + annotate_candidates + + # One-line summary: total / clean / changed + local _total=${#candidates[@]} _clean _changed + _clean=$(printf '%s\n' "${candidates[@]}" | grep -c ' (✓)$' || true) + _changed=$((_total - _clean)) + echo "Fetched $_total projects: $_clean clean, $_changed with changes" >&2 + + selections=$(printf '%s\n' "${candidates[@]}" | fzf --multi --height=70% --reverse) || true + [ -z "$selections" ] && return 0 + + read_selections "$selections" + + if ! tmux has-session -t "$SESSION" 2>/dev/null; then + # Create session with first selection + local first="${selected[0]}" + local dir name + dir="${first/#\~/$HOME}" + name="$(basename "$dir")" + auto_pull_if_clean "$dir" + first_wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') + tmux send-keys -t "$first_wid" "$CLAUDE_CMD \"$CLAUDE_INSTRUCTIONS\"" Enter + for entry in "${selected[@]:1}"; do + dir="${entry/#\~/$HOME}" + name="$(basename "$dir")" + auto_pull_if_clean "$dir" + create_window "$dir" "$name" > /dev/null + done + else + # Add windows to existing session + for entry in "${selected[@]}"; do + local dir name wid + dir="${entry/#\~/$HOME}" + name="$(basename "$dir")" + auto_pull_if_clean "$dir" + wid=$(create_window "$dir" "$name") + [ -z "$first_wid" ] && first_wid="$wid" + done + fi + + sort_windows + [ -n "$first_wid" ] && tmux select-window -t "$first_wid" + attach_session +} + +# ---------- dispatch ---------- + +case "${1:-}" in + -h|--help) + usage + ;; + --attach) + attach_mode + ;; + "") + multi_mode + ;; + *) + for arg in "$@"; do + single_mode "$arg" + done + ;; +esac |
