aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-26 01:57:48 -0500
committerCraig Jennings <c@cjennings.net>2026-05-26 01:57:48 -0500
commitf9c72c817290bb5433e593b2a8d1cfaa25431d20 (patch)
treefed103aec3faf75624a2a969fd67dea71e501a4d
parent98382929852b213f8dc8b1ba720cc0d1861159b6 (diff)
downloadrulesets-f9c72c817290bb5433e593b2a8d1cfaa25431d20.tar.gz
rulesets-f9c72c817290bb5433e593b2a8d1cfaa25431d20.zip
refactor(workflows): split triage-intake into engine + source plugins
The triage-intake workflow had every source baked into one file, so adding or changing a source meant editing the workflow itself. I replaced it with a source-agnostic engine plus per-source plugins named triage-intake.<source>.org. The engine carries the anchor/sentinel logic, the four-bucket model, the Phase A-D orchestration, the todo.org persistence convention, and the exit criteria. Each source's scan, classify, render, and action knowledge moved into its own plugin. Four general plugins ship in the template: personal-gmail, personal-calendar, cmail, and github-prs. Project-specific sources live in the project's .ai/project-workflows/ and are never synced. Phase 0 globs both directories so a project source can't silently drop out of the sweep. I taught INDEX.org and the startup workflow-discovery drift check the namespace. A file matching <engine>.*.org is a plugin of that engine, not an orphan, and gets no trigger entry of its own. A "run the triage-intake workflow" request routes to the engine, never to a plugin.
-rw-r--r--.ai/workflows/INDEX.org7
-rw-r--r--.ai/workflows/startup.org6
-rw-r--r--.ai/workflows/triage-intake.cmail.org53
-rw-r--r--.ai/workflows/triage-intake.github-prs.org71
-rw-r--r--.ai/workflows/triage-intake.org329
-rw-r--r--.ai/workflows/triage-intake.personal-calendar.org53
-rw-r--r--.ai/workflows/triage-intake.personal-gmail.org53
-rw-r--r--claude-templates/.ai/workflows/INDEX.org7
-rw-r--r--claude-templates/.ai/workflows/startup.org6
-rw-r--r--claude-templates/.ai/workflows/triage-intake.cmail.org53
-rw-r--r--claude-templates/.ai/workflows/triage-intake.github-prs.org71
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org329
-rw-r--r--claude-templates/.ai/workflows/triage-intake.personal-calendar.org53
-rw-r--r--claude-templates/.ai/workflows/triage-intake.personal-gmail.org53
14 files changed, 962 insertions, 182 deletions
diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org
index bad2a48..a61b824 100644
--- a/.ai/workflows/INDEX.org
+++ b/.ai/workflows/INDEX.org
@@ -8,7 +8,7 @@ Single-source catalog of every workflow in this directory, with the trigger phra
* 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).
+This index must list every =.org= file in =.ai/workflows/= except this one and except *source plugins*. A source plugin is any file matching =<indexed-workflow>.*.org= (a second dot-segment after an indexed engine name, e.g. =triage-intake.personal-gmail.org=) — it belongs to its engine, is not independently triggerable, and gets no entry of its own. Startup verifies the index matches the directory and flags drift (missing entries or stale entries pointing at deleted files), treating plugins as owned by their engine rather than as orphans.
* Catalog
@@ -33,8 +33,9 @@ This index must list every =.org= file in =.ai/workflows/= except this one. Star
- =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"
+- =triage-intake.org= — on-demand triage *engine*: a source-agnostic sweep that loads source plugins, classifies what's new since last check (Action / FYI / Noise-keep / Noise-trash), produces one synthesized summary, and offers to run the routine actions. Carries no sources of its own — every source comes from a =triage-intake.<source>.org= plugin globbed from both =.ai/workflows/= (general) and =.ai/project-workflows/= (project-specific). Lighter scope than =daily-prep.org='s triage section.
+ - Triggers: "do a triage intake", "triage intake", "what's new?", "what's new since I last checked", "do a sweep", "check email, calendar, and PRs"
+ - Source adapters: =triage-intake.*.org= (=personal-gmail=, =personal-calendar=, =cmail=, =github-prs= ship general; projects add their own). Not independently triggerable — the engine loads them; "run the triage-intake workflow" always routes to the engine, never a plugin.
- =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.
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index 8aa420c..2371d11 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -158,12 +158,14 @@ 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).
+*Engine + plugin naming convention.* A workflow file may be an *engine* that owns *source plugins*. The convention is dot-delimited: =engine-name.org= is the engine; =engine-name.plugin-name.org= is a plugin of it. The first dot after the engine name is the engine/plugin boundary; hyphens stay *inside* a segment (=triage-intake.personal-gmail.org= is engine =triage-intake=, plugin =personal-gmail=). The glob =engine-name.*.org= matches the plugins but not the engine (the engine has no second dot-segment), so an engine never loads itself. Deeper dots (=engine-name.plugin-name.sub-name.org=) are reserved for sub-adapters — unused for now, accommodated at no cost. This generalizes: any future engine =foo.org= owns =foo.*.org= plugins. Plugins are not independently triggerable — they have no index entry, and a "run the [engine] workflow" request always routes to the engine, never a plugin.
+
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.
+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, surface the mismatch and offer to update INDEX.org. If a file in the directory has no index entry, first test whether it's a *source plugin* — any file matching =<indexed-engine>.*.org= (a second dot-segment after an indexed engine name) belongs to that engine and is correctly absent from the index; do not flag it. Flag only genuine orphans: a non-plugin =.org= with no entry. Don't silently route around real drift.
+4. *Match the request* against the index trigger phrases first, then against project-workflow filenames if no index hit. A request matching an engine routes to =engine-name.org=, never to one of its =engine-name.plugin-name.org= plugins — the engine loads its own plugins at run time.
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.
diff --git a/.ai/workflows/triage-intake.cmail.org b/.ai/workflows/triage-intake.cmail.org
new file mode 100644
index 0000000..d818c72
--- /dev/null
+++ b/.ai/workflows/triage-intake.cmail.org
@@ -0,0 +1,53 @@
+#+TITLE: Triage Intake — cmail (Proton) Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+
+* Source: cmail
+:PROPERTIES:
+:ORDER: 25
+:ENABLED: test -f .ai/scripts/cmail-action.py
+:ANCHOR: none
+:SUBAGENT_OVER: 50
+:END:
+
+** Scan
+
+Proton (=c@cjennings.net=) via the bridge script. =ANCHOR: none= because this reports live IMAP unread *state*, not a since-window — the engine substitutes no cutoff. Phase B uses the anchor only to flag which of the current unread arrived since last check.
+
+#+begin_src bash
+python3 .ai/scripts/cmail-action.py list-unread
+#+end_src
+
+JSON output, keyed by UID. The script ignores messages already flagged =\Deleted= (those are pending-flush on the next Proton sync), so the list is the genuinely-live unread set.
+
+** Classify
+
+Bias: *trash-leaning*, like personal Gmail — cmail catches a lot of forwarded noise.
+
+- *Noise-trash:* newsletters, marketing, social, automated alerts.
+- *Noise-keep:* receipts, statements.
+- *FYI:* substantive mail, no action owed.
+- *Action:* an explicit ask or reply owed. Flag "new since last check" by comparing the message date against the engine's anchor.
+
+** Render
+
+#+begin_example
+**cmail (Proton) — N unread.** <one-line classification summary>
+- Action: <items, if any>
+- FYI: <items, if any>
+- Noise: N trash candidates, M keep
+#+end_example
+
+Omit if zero unread.
+
+** Actions
+
+All take one or more UIDs (from the =list-unread= JSON):
+
+- mark-read :: =python3 .ai/scripts/cmail-action.py mark-read <uid>=
+- star :: =python3 .ai/scripts/cmail-action.py star <uid>=
+- unstar :: =python3 .ai/scripts/cmail-action.py unstar <uid>=
+- trash :: =python3 .ai/scripts/cmail-action.py trash <uid>= (flags =\Deleted=; flushed on next Proton sync)
diff --git a/.ai/workflows/triage-intake.github-prs.org b/.ai/workflows/triage-intake.github-prs.org
new file mode 100644
index 0000000..c1bc796
--- /dev/null
+++ b/.ai/workflows/triage-intake.github-prs.org
@@ -0,0 +1,71 @@
+#+TITLE: Triage Intake — Personal GitHub PRs Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+#
+# This is the GENERAL (github.com) PR source. A work project on GitHub
+# Enterprise declares its own triage-intake.<work>-prs.org plugin with
+# --hostname and its own repos/conventions; it does not edit this file.
+
+* Source: github-prs
+:PROPERTIES:
+:ORDER: 40
+:ENABLED: command -v gh && gh auth status
+:ANCHOR: iso8601
+:SUBAGENT_OVER: 40
+:END:
+
+** Scan
+
+Open PRs needing Craig's attention across his personal GitHub projects. Two queries — PRs returns *state*, so the anchor is advisory (the engine substitutes =<anchor-iso8601>= into the optional =--updated= filter for recency; open-PR state is the real signal):
+
+#+begin_src bash
+# PRs Craig opened
+gh search prs --author=@me --state=open \
+ --json number,title,repository,state,isDraft,updatedAt,url
+
+# PRs awaiting Craig's review
+gh search prs --review-requested=@me --state=open \
+ --json number,title,repository,state,isDraft,updatedAt,url
+#+end_src
+
+=@me= resolves to the authenticated personal GitHub account, so no username is hardcoded. Add =--owner <account-or-org>= to scope to specific personal projects when the full-account search is too broad.
+
+=gh search prs= can't return =reviewDecision= or CI status. For the handful that survive Phase B as Action candidates, drill in per-PR:
+
+#+begin_src bash
+gh pr view <N> --repo <repo> --json reviewDecision,statusCheckRollup,mergeable
+#+end_src
+
+** Classify
+
+Action items, in priority order:
+
+- *Craig's PRs that are mergeable* — approved, or no review required on a solo project, with CI green and no outstanding CHANGES_REQUESTED.
+- *Craig's PRs with CHANGES_REQUESTED* — needs his response: rework and re-request, or push back with reasoning.
+- *PRs awaiting Craig's review* — review requested from him, or new commits since his last review on a PR he's reviewing.
+- *@mentions on any PR* — explicit ask.
+
+FYI: approval landings with no remaining action, merge events on PRs already shipped, comments without questions.
+
+Noise: stale draft updates, bot/CI status pings.
+
+** Render
+
+#+begin_example
+**PRs (<repo or "personal">) — N open.**
+- Mergeable now: <list>
+- Changes requested on yours: <list>
+- Review owed: <list>
+#+end_example
+
+Omit if nothing open needs attention.
+
+** Actions
+
+- merge :: =gh pr merge <N> --repo <repo> --squash --delete-branch= when no other PR stacks on the branch (verify via =gh pr list --repo <repo> --search "base:<branch-name>"=). If something stacks, =--squash= without =--delete-branch= and rebase the dependent PR afterward.
+- comment :: =gh pr comment <N> --repo <repo>=
+- review :: =gh pr review <N> --repo <repo>= (per Craig's call — approve / request-changes / comment)
+- branch-clean :: =git push origin --delete <branch>= when the merge happened earlier and only branch deletion remains.
diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org
index 02e36e8..58b6709 100644
--- a/.ai/workflows/triage-intake.org
+++ b/.ai/workflows/triage-intake.org
@@ -1,126 +1,283 @@
-#+TITLE: Triage-Intake Workflow
+#+TITLE: Triage Intake Workflow (Engine)
#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-05-11
+#+DATE: 2026-05-01
* 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.
+Lightweight, between-meetings sweep across whatever sources are plugged in — email, calendar, chat, open PRs, ticketing. Classifies what came in since the last check (Action / FYI / Noise-keep / Noise-trash), produces a single synthesized summary, and offers to execute the routine actions (trash, mark-read, star, respond, merge, attachment fetch).
-* When to Use
+Think of it as the ER intake queue: every new message, invite, and PR notification is a "patient" walking through the door. This workflow is the triage nurse looking at the queue and telling Craig what needs attention now, what's just FYI, and what can be cleared.
-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"
+*This file is the engine.* It carries no sources of its own. Every source it scans comes from a *source plugin* — a =triage-intake.<source>.org= file the engine loads at Phase 0. The engine is source-agnostic and project-agnostic; the project- and account-specific knowledge lives entirely in the plugins. To add a source, drop a plugin file. To change one, edit its plugin. Never wire a source into this file.
-* The Workflow
+Distinct from =daily-prep.org=:
+- *daily-prep* — heavier, once daily, builds the day's plan + standup brief + meeting prep + time blocks.
+- *triage-intake* — fast, repeatable, just answers "what's new since last check?"
-** Phase 1: Fan-out (one parallel batch)
+* When to Use This Workflow
-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.
+Trigger phrases:
-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.
+- "Run a triage-intake"
+- "Triage intake"
+- "What's new" / "What's new since I last checked"
+- "Do a sweep" / "Do a triage sweep"
+- "Check email, calendar, and PRs"
-** Phase 2: Synthesize + Linear Dev-Review sweep
+Typical timing:
-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:
+- Between meetings (1-2 minute glance)
+- After a long focused-work block
+- Before context-switching to a new task
+- When ambient anxiety about "did I miss something?" creeps in
-- *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.
+Do *not* use when running daily-prep — daily-prep already does this as Phase 3.
-*** Linear Dev-Review sweep
+* Source Plugin Contract
-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:
+A source plugin is a file named =triage-intake.<source>.org=. The first dot after =triage-intake= is the engine/plugin boundary; the segment after it is the source id. Hyphens stay *inside* a segment (=triage-intake.personal-gmail.org= is engine =triage-intake=, source =personal-gmail=). Deeper dots (=triage-intake.<source>.<sub>.org=) are reserved for sub-adapters — YAGNI for now, but the namespace accommodates them at no cost.
+
+A plugin file declares exactly one source through a fixed shape:
+
+*Property drawer* on the top-level =* Source:= heading:
+- =ORDER= — integer. Output ordering in the per-source breakdown (lower = earlier).
+- =ENABLED= — the precondition the engine evaluates before loading the source. The source is skipped — *with an announced reason* — when it's false. Forms: =always=, a shell test (=command -v gh && gh auth status=), or =mcp <server> present=.
+- =ANCHOR= — the cutoff format this source consumes: =epoch=, =iso8601=, =day=, or =none= (state-based source with no since-window — e.g. live IMAP unread, or open-PR state). The engine computes the anchor once and substitutes it in the requested format.
+- =SUBAGENT_OVER= — integer. If the scan is expected to return more than this many items, dispatch a subagent for the source so its raw output stays out of main context. The subagent applies Phase B and returns buckets only, not the raw list.
+
+*Body sections:*
+- =** Scan= — the command(s) that fetch new/unread items since =<anchor>=, emitting raw items.
+- =** Classify= — the source's per-bucket bias and noise patterns. *Deltas* from the engine's shared four-bucket model below, not a re-derivation.
+- =** Render= — the source's block in the Phase C summary. "Omit if empty."
+- =** Actions= — the executable state-changes, one verb per line: =verb :: command template (parameterized by item id)=.
+
+Template:
+
+#+begin_example
+* Source: <id>
+:PROPERTIES:
+:ORDER: <n>
+:ENABLED: <precondition>
+:ANCHOR: epoch | iso8601 | day | none
+:SUBAGENT_OVER: <n>
+:END:
+
+** Scan
+<command(s) that fetch new/unread items since <anchor>>
+
+** Classify
+<bias + noise patterns; deltas from the shared four-bucket model>
+
+** Render
+"<Source label> — N <unit>" block; omit if empty.
+
+** Actions
+- <verb> :: <command, parameterized by <id>>
+#+end_example
+
+* Phase 0 — Load source plugins (MANDATORY — do not skip)
+
+The engine has no sources baked in. It discovers them by globbing *two* directories, and you MUST glob *both*:
+
+#+begin_src bash
+ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2>/dev/null
+#+end_src
+
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, personal GitHub PRs).
+- =.ai/project-workflows/triage-intake.*.org= — *PROJECT-SPECIFIC* source plugins, never synced, owned by this project (e.g. a work project's Linear, work Gmail, work Slack, enterprise-GitHub PRs).
+
+⚠ *THE #1 FAILURE MODE — read this twice.* Globbing only =.ai/workflows/= and silently missing every project plugin. If you skip =.ai/project-workflows/=, the sweep runs with *half its sources* and Craig never learns what it dropped — the omission is invisible, because a missing source looks identical to a quiet source in the output. There is no error, no empty block, no warning. The sweep just lies by omission. *Glob both directories. Always.*
+
+The glob exclude is automatic: =triage-intake.*.org= matches the plugins but not this engine file (=triage-intake.org= has no second dot-segment), so the engine never loads itself.
+
+After globbing, for each plugin file:
+1. Read it.
+2. Evaluate its =ENABLED= precondition. If false, *announce the skip with its reason* ("skipping linear — mcp__linear not present") and move on.
+3. The surviving set is the source list for Phases A-D.
+
+*Announce the loaded set before scanning* so the omission can't hide:
+
+#+begin_example
+Loaded 5 source plugins:
+ general: personal-gmail, personal-calendar, cmail, github-prs
+ project: deepsat-gmail
+ skipped: linear (mcp__linear not present)
+#+end_example
+
+If the project directory glob returns nothing, say so explicitly ("no project plugins in .ai/project-workflows/") rather than staying silent — silence is indistinguishable from forgetting to look.
+
+* Anchor: Since When?
+
+The workflow needs a "scan since" timestamp. Resolution order:
+
+1. *Sentinel file content:* first whitespace-delimited token in =.ai/last-triage-intake= is the Phase A scan-kickoff epoch from the most recent successful run (see "Capture the Phase A timestamp" below). Most accurate.
+2. *Sentinel file mtime* (back-compat): if the file exists but is empty, read its mtime — that's the older mtime-based convention that pre-dates the content-based change. Still accurate on the machine that wrote it.
+3. *Most recent prep doc:* if no sentinel content or readable mtime, anchor on the latest =inbox/YYYY-MM-DD-daily-prep.org= or =daily-prep/YYYY-MM-DD-daily-prep.org= mtime.
+4. *Most recent session file:* if none of the above, anchor on the most recent =.ai/sessions/= file's mtime.
+5. *Session start:* fall back to the current session's start time. Last resort.
+
+The engine computes the anchor *once* and exposes it in every format a plugin might request (=epoch=, =iso8601=, =day=). Each plugin's =ANCHOR= field says which it consumes; the engine substitutes that form into the plugin's =<anchor>= placeholder. Sources with =ANCHOR: none= are state-based (live unread, open-PR state) and get no cutoff substituted — they report current state, and Phase B uses the anchor only to flag what's *new since* last check.
+
+** Capture the Phase A timestamp
+
+Just before issuing the Phase A batch, capture the current epoch seconds:
+
+#+begin_src bash
+PHASE_A_TS=$(date +%s)
+#+end_src
+
+Hold this value through Phases B, C, and D. At end of run, *write* the captured timestamp into the sentinel's content (not its mtime):
+
+#+begin_src bash
+echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake
+#+end_src
+
+The file ends up with a single line like =1778683109 2026-05-13 09:38:29 -0500= — epoch first (machine-readable, parsed by reading the first token), human-readable timestamp second.
+
+*Why content, not mtime:* the sentinel is checked into git. Git tracks content, not mtime, so an mtime-based sentinel is per-machine: one machine's anchor stays on that machine; a fresh clone gets the file but the mtime is whenever the clone happened, not the actual triage time. Writing the epoch as content means the anchor travels with the repo and stays accurate after a fetch + pull on any machine.
+
+*Why Phase A and not end-of-run:* Phase A runs at one moment, but Phases B-D may take 5-30 minutes. Items posted to any source /during/ Phases B-D land between the Phase A scan time and the eventual end-of-run time. If the sentinel were set to the end-of-run time, those items would silently fall through the cracks: the next triage's Phase A would skip the gap window and never see them. Anchoring the sentinel to Phase A's scan time guarantees the next run's window starts where this run's window ended, with zero gap.
+
+** Reading the sentinel
+
+When the workflow needs the anchor at the start of a new run:
#+begin_src bash
-gh pr list --search "head:<gitBranchName>" --state all \
- --json number,state,headRefName,mergedAt,title
+# Content-first, mtime-fallback.
+ANCHOR_EPOCH=$(awk 'NR==1 {print $1; exit}' .ai/last-triage-intake 2>/dev/null)
+if [ -z "$ANCHOR_EPOCH" ] && [ -f .ai/last-triage-intake ]; then
+ ANCHOR_EPOCH=$(stat -c %Y .ai/last-triage-intake)
+fi
#+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 both fail, fall through to the resolution order above (prep doc → session file → session start).
+
+* Approach: Phases A → D
+
+** Phase A: Fan-out (one parallel batch)
+
+Issue every enabled source's =Scan= command in a single message, with the anchor substituted in each source's declared format. They have no dependencies and benefit from running concurrently.
+
+Per-source subagent escalation: if a source's scan is expected to return more than its =SUBAGENT_OVER= count (e.g. personal Gmail after a multi-day gap), dispatch a subagent for that source. The subagent applies Phase B classification and returns the synthesized buckets, not the raw item list.
+
+** Phase B: Classify per source (shared four-bucket model)
+
+Every item lands in one bucket. Plugins refine these with source-specific bias and noise patterns in their =Classify= section; they do not redefine the buckets.
+
+- *Action* — needs Craig to do something: an explicit ask, a decision needed, blocked-on-Craig, a mergeable PR, an invite needing a response, a deadline inside 48h.
+- *FYI* — substantive context worth seeing, but no action owed.
+- *Noise-keep* — low value but worth retaining (audit trail, receipts).
+- *Noise-trash* — safe to discard: newsletters, marketing, social digests, bot pings, redundant aggregator digests, wrong-recipient mail, past-event artifacts.
+
+Per-source bias (a work email account leans keep for audit value; a personal account leans trash on high noise volume) lives in each plugin's =Classify= section. Read it from there; don't re-derive it here.
+
+** Phase C: Synthesize a single summary
+
+One markdown summary surfaced inline to Craig. Order:
+
+1. *Top signals to act on* — bullet list of 3-7 items, ordered by urgency, *Action only*. Each bullet links to the source (permalink, thread URL, PR number).
+2. *Per-source breakdown* — one short section per *loaded* source, in =ORDER=, using that plugin's =Render= shape: Action items detailed, FYI items as a short list, Noise as a tally only ("Noise: 12 trash candidates, 4 keep, 0 starred").
+3. *Suggested actions* — explicit list of state changes Craig could take this run (trash these N messages, mark-read these M, star this Action item, respond to this invite, merge PRs #X and #Y, etc.).
-If a Dev-Review ticket's PR is *merged*, propose a status move:
+Format target: scannable in 30 seconds, full read in 2 minutes. Don't pad. If a source returned zero items, write "Calendar — quiet" or "PRs — nothing new since 14:30 yesterday."
-- *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.
+*** Sub-step: write each Action item into =todo.org= as its own =:quick:= task
-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.
+After surfacing the summary inline, append every Action item — regardless of source — to =todo.org= as its own top-level =** TODO= heading carrying the =:quick:= tag plus =:reactive:= and any relevant person/entity tag.
-Skip the sweep entirely if the project doesn't use Linear (personal projects, the rulesets repo, etc.).
+Each Action item is one task. Don't group items by source under =** Email Response=, =** PR Review=, etc. sub-headings. Each response is its own filterable task so Craig can re-prioritize, =SCHEDULE:= / =DEADLINE:=, or tag individually.
-** Phase 3: Mark-as-read (at the end — not as you go)
+Format:
-Clear unread state. Default behavior unless the user explicitly says "leave this unread."
+#+begin_example
+** TODO [#B] Merge PR #42 on archsetup (approved, CI green) — [[https://github.com/<user>/archsetup/pull/42][PR #42]] :quick:reactive:
+** TODO [#B] Respond to the 2pm reschedule invite from Dana :quick:reactive:
+** TODO [#B] Reply to the contract-terms email thread :quick:reactive:
+#+end_example
-*** Email — every unread INBOX message, all three accounts
+Rules:
-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."
+- Heading is plain prose. Lead with the verb (Read / Re-review / Reply / Respond / Address / Merge / Schedule).
+- Priority: default =[#B]= for fresh reactive items. Bump to =[#A]= only if blocking someone or a deadline lands inside 7 days.
+- Tags: always =:quick:= + =:reactive:=. Add person/entity tags when the dependency is sharp.
+- Link the source in the heading when it has a URL (GitHub PR, mail thread, chat permalink). Use org's =[[url][label]]= form so the heading stays clickable in Emacs.
+- *Record the source locator in the task body* so a reply can be routed back to where the request came from — the channel + thread id for chat, the repo + PR number, the message id for mail. The general rule: a reply goes back to the *origin* of the request, not a fixed notification channel. (Project plugins may add stricter routing rules in their own files.)
+- Placement: append at end of =* Work Open Work= (just before =* Work Incubate=) unless the project's =todo.org= has a designated triage section near the top (=* Triage= or =* Inbox=).
-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='l'
- # --fields='l' is the file location (full path). Don't use 'p' — in current
- # mu that's the priority field and returns "normal" for every row, which
- # makes the flag manager error on every path (caught 2026-05-22).
- #+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.
+This sub-step makes triage-intake's findings *persist* in =todo.org= instead of evaporating after the inline summary.
-*** Slack — every conversation surfaced
+** Phase D: Execute actions on confirmation
-=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.
+Wait for Craig's go-ahead before running any state changes. Default to single-confirmation for the whole batch ("yes" → run everything proposed). Craig may also pick a subset ("trash personal but hold the work account") or hand back a different plan ("trash all but star the expense thread and queue PR merges for after lunch").
-*** Skip
+Each action dispatches to the owning source plugin's =Actions= verb (trash, mark-read, star, respond, merge, comment, attachment-fetch). The engine doesn't hardcode action commands — it reads them from the loaded plugins. Read each plugin's =Actions= section for the exact command.
-Linear, GitHub PRs, and calendars — none has a clean "mark this read" concept that maps to inbox hygiene.
+After actions complete, write the Phase A capture into the sentinel's *content* (see "Capture the Phase A timestamp"): =echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake=. Do not use plain =touch= (writes mtime to /now/ and strands items posted between Phase A and end of run) and do not use =touch -d "@$PHASE_A_TS"= (correct timestamp but mtime is per-machine — won't survive a fresh clone or cross-machine sync).
-** Phase 4: Report
+*Do not close the workflow yet.* See Exit Criteria below.
-One concise summary back to the user:
+** Exit Criteria
-- *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.
+The workflow stays open until Craig has *explicitly* either:
-* Principles
+1. *Confirmed* that the executed actions are sufficient and nothing more is needed this round, or
+2. *Handed back a different plan* (e.g., "actually hold the PR merges, address #131 first").
-- *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.
+A successful Phase D run is *not* an exit signal. After the action batch returns, surface what shipped and wait. Don't volunteer "done" or "all set" — those are exit-claim phrases that pre-empt Craig's call. Use a status report ("17 actions succeeded, sentinel written at 12:19") and stop.
+
+If Craig has been silent for a while after Phase D and the surface looks closed-out, *ask*: "Anything else on this triage, or are we good to close out?" Don't auto-terminate.
+
+This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in.
+
+* Output Template
+
+The summary follows this shape (omit any source that genuinely returned nothing; render one block per *loaded* source in =ORDER=, using each plugin's =Render= shape):
+
+#+begin_example
+**Anchor:** <previous run timestamp> → now (<elapsed> elapsed)
+**Loaded:** <general plugins> + <project plugins> (skipped: <disabled, with reason>)
+
+**Top signals to act on:**
+1. <terse Action description with link>
+2. ...
+
+<one block per loaded source, in ORDER — see each plugin's Render>
+
+**Suggested actions:**
+- Trash N noise items
+- Mark-read M keep items
+- Respond to <invite>
+- Merge PRs #X and #Y
+- ...
+#+end_example
+
+Order matters: top-signals first because that's what Craig reads in 30 seconds between meetings. Per-source detail second. Suggested actions last because they require a decision.
+
+* Common Mistakes
+
+1. *Globbing only =.ai/workflows/= and missing the project plugins.* The single most damaging failure mode — the sweep runs with half its sources and the omission is invisible (a missing source looks identical to a quiet one). Phase 0 globs *both* =.ai/workflows/triage-intake.*.org= and =.ai/project-workflows/triage-intake.*.org=, every run, and announces the loaded set.
+2. *Running Phase A sequentially.* Send every enabled source's scan in one message — the whole point is parallelism.
+3. *Wiring a source into the engine.* Sources live in plugin files, never here. If you find yourself editing this file to add an account, repo, or channel, stop — write or edit a =triage-intake.<source>.org= plugin instead.
+4. *Executing actions without explicit confirmation.* Phase D runs only after Craig says "yes" or picks a subset.
+5. *Forgetting to set the sentinel at the end.* Without it, the next run re-scans the same window.
+6. *Using mtime instead of content for the sentinel.* Plain =touch= writes /now/ to mtime, stranding items posted between Phase A and end of run. =touch -d "@$PHASE_A_TS"= fixes the time but mtime is per-machine — git tracks content, not metadata, so the anchor doesn't survive a clone or cross-machine sync. Always write the epoch into the file's *content*.
+7. *Running this alongside daily-prep.* Daily-prep already does this as Phase 3 — don't duplicate.
+8. *Mixing Action and FYI in the top-signals list.* Top signals = Action only. FYI lives in the per-source detail.
* 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.).
+Update the engine as the orchestration pattern evolves; update a plugin as its source evolves. Source-specific learnings belong in the plugin's own file, not here.
+
+** Updates and Learnings
+
+*** 2026-05-01: Initial creation
+Extracted from daily-prep's Phase 3 pattern as a standalone, lightweight, between-meetings sweep.
+
+*** 2026-05-07: Anchor the sentinel to Phase A scan time, not run-end time
+Gap-window bug: a run had Phase A fire at 13:35 and the sentinel set at 15:04, so an item posted at 14:20 would be skipped by the next run (the sentinel claimed everything before 15:04 was scanned when Phase A only reached 13:35). Fix: capture =PHASE_A_TS= just before Phase A, hold it through B-D, write it to the sentinel at end of run. The sentinel means "everything before this timestamp has been scanned," the only invariant that prevents items falling through the cracks.
+
+*** 2026-05-13: Move the sentinel from mtime to content (cross-machine survivability)
+The sentinel is checked into git, but git tracks content, not mtime — so an mtime anchor is per-machine. Fix: write the captured epoch into the sentinel's content (=EPOCH ISO-8601=), read with =awk 'NR==1 {print $1}'=, mtime as back-compat fallback.
+
+*** 2026-05-26: Refactor into engine + source plugins
+Split the monolithic workflow into a source-agnostic engine (this file) and per-source plugins named =triage-intake.<source>.org=. The engine carries the anchor/sentinel logic, the four-bucket model, the Phase A-D orchestration, the todo.org persistence convention, and the exit criteria. Each source's scan/classify/render/action knowledge moved to its own plugin. General plugins (personal-gmail, personal-calendar, cmail, github-prs) live in =.ai/workflows/= and are template-synced; project-specific plugins (a work project's Linear, work Gmail, work Slack, enterprise PRs) live in the project's =.ai/project-workflows/= and are never synced. Phase 0 globs *both* directories — the loud requirement, because missing the project dir silently halves the sweep. Naming convention: first dot is the engine/plugin boundary, deeper dots reserved for sub-adapters. This removed all DeepSat/Linear specifics from the engine; they become work-project plugins.
diff --git a/.ai/workflows/triage-intake.personal-calendar.org b/.ai/workflows/triage-intake.personal-calendar.org
new file mode 100644
index 0000000..bf7d543
--- /dev/null
+++ b/.ai/workflows/triage-intake.personal-calendar.org
@@ -0,0 +1,53 @@
+#+TITLE: Triage Intake — Personal Calendar Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+
+* Source: personal-calendar
+:PROPERTIES:
+:ORDER: 30
+:ENABLED: mcp google-calendar present
+:ANCHOR: iso8601
+:SUBAGENT_OVER: 40
+:END:
+
+** Scan
+
+Calendar isn't an unread queue — it's two signals: what's *coming up soon* and what *changed since last check*. Pull the near-horizon window for the personal account, then let Classify split it by the anchor.
+
+#+begin_src text
+mcp__google-calendar__list-events account="personal" timeMin="<now-iso8601>" timeMax="<now+36h-iso8601>" singleEvents=true orderBy="startTime"
+#+end_src
+
+The event objects carry =created=, =updated=, and the attendee =responseStatus=. The engine supplies =<anchor-iso8601>= so Classify can flag events whose =updated= is after the anchor as "new/changed since last check." (=list-events= filters by start-time window, not by updated-since, so the change detection happens in Classify, not the query.)
+
+** Classify
+
+Calendar leans *FYI* — most upcoming events are just awareness. The Action cases are narrow:
+
+- *Action:*
+ - A new invite with =responseStatus=needsAction= (Craig hasn't replied).
+ - A reschedule or cancellation of an event Craig had — =updated= after the anchor and the time/status changed.
+ - An event starting within ~2h that Craig might have lost track of.
+- *FYI:* upcoming events today/tomorrow, unchanged since last check.
+- *Noise-keep / trash:* declined events, all-day informational banners, automated holiday entries — tally only, no action.
+
+Surface "changed since last check" prominently — a reschedule that slips by is the expensive miss.
+
+** Render
+
+#+begin_example
+**Calendar (personal) — N upcoming, M changed since last check.**
+- Action: <invites needing a response, reschedules, imminent events>
+- Upcoming: <today/tomorrow events, terse>
+#+end_example
+
+Omit the block if nothing upcoming and nothing changed.
+
+** Actions
+
+- respond :: =mcp__google-calendar__respond-to-event= account="personal" eventId=<event-id> responseStatus=<accepted|declined|tentative>
+
+Read-only otherwise — triage surfaces calendar state; it doesn't create or move events (that's the add/edit-calendar-event workflows).
diff --git a/.ai/workflows/triage-intake.personal-gmail.org b/.ai/workflows/triage-intake.personal-gmail.org
new file mode 100644
index 0000000..aa0554d
--- /dev/null
+++ b/.ai/workflows/triage-intake.personal-gmail.org
@@ -0,0 +1,53 @@
+#+TITLE: Triage Intake — Personal Gmail Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+
+* Source: personal-gmail
+:PROPERTIES:
+:ORDER: 20
+:ENABLED: mcp google-docs-personal present
+:ANCHOR: epoch
+:SUBAGENT_OVER: 50
+:END:
+
+** Scan
+
+Personal Gmail unread in the inbox since the anchor:
+
+#+begin_src text
+mcp__google-docs-personal__listMessages q="is:unread in:inbox after:<anchor-epoch>" maxResults=100
+#+end_src
+
+⚠ *Express the cutoff as the literal UNIX epoch* — =after:1778856990=, not =after:YYYY/MM/DD=. Gmail's =after:YYYY/MM/DD= operator only supports day resolution; the =YYYY/MM/DD HH:MM:SS= form is NOT valid syntax — Gmail parses the space as a term separator, treats =HH:MM:SS= as a search term that never matches, and returns 0 results, silently masking unread mail. The engine supplies =<anchor-epoch>= because this source declares =ANCHOR: epoch=.
+
+⚠ *Do NOT add =-category:promotions -category:social=.* That filter masked 67 promo+social messages across two runs (2026-05-04, 2026-05-06), both needing a follow-up sweep. Pull the full unfiltered set; the trash-leaning bias in Classify handles promotions and social directly.
+
+** Classify
+
+Bias: *trash-leaning* — personal Gmail is high noise volume.
+
+- *Noise-trash:* newsletters, Substacks, retail/SaaS marketing, social digests, redundant aggregator digests (Notion/Miro daily), wrong-recipient mail, past-event calendar artifacts.
+- *Noise-keep:* receipts, order confirmations, statements — low value but worth the audit trail.
+- *FYI:* substantive personal mail with no action owed.
+- *Action:* an explicit ask, a reply owed, a time-sensitive personal matter.
+
+** Render
+
+#+begin_example
+**Personal Gmail — N unread.** <one-line classification summary>
+- Action: <items, if any, with thread links>
+- FYI: <items, if any>
+- Noise: N trash candidates, M keep
+#+end_example
+
+Omit the block if zero unread.
+
+** Actions
+
+- trash :: =mcp__google-docs-personal__trashMessage= id=<message-id> (recoverable from Gmail Trash for 30 days)
+- mark-read :: =mcp__google-docs-personal__modifyMessageLabels= id=<message-id> removeLabelIds=["UNREAD"]
+- star+read :: =mcp__google-docs-personal__modifyMessageLabels= id=<message-id> addLabelIds=["STARRED"] removeLabelIds=["UNREAD"]
+- attach-fetch:: =.ai/scripts/gmail-fetch-attachments.py --profile personal --message-id <message-id> --output-dir <PATH>=
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index bad2a48..a61b824 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -8,7 +8,7 @@ Single-source catalog of every workflow in this directory, with the trigger phra
* 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).
+This index must list every =.org= file in =.ai/workflows/= except this one and except *source plugins*. A source plugin is any file matching =<indexed-workflow>.*.org= (a second dot-segment after an indexed engine name, e.g. =triage-intake.personal-gmail.org=) — it belongs to its engine, is not independently triggerable, and gets no entry of its own. Startup verifies the index matches the directory and flags drift (missing entries or stale entries pointing at deleted files), treating plugins as owned by their engine rather than as orphans.
* Catalog
@@ -33,8 +33,9 @@ This index must list every =.org= file in =.ai/workflows/= except this one. Star
- =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"
+- =triage-intake.org= — on-demand triage *engine*: a source-agnostic sweep that loads source plugins, classifies what's new since last check (Action / FYI / Noise-keep / Noise-trash), produces one synthesized summary, and offers to run the routine actions. Carries no sources of its own — every source comes from a =triage-intake.<source>.org= plugin globbed from both =.ai/workflows/= (general) and =.ai/project-workflows/= (project-specific). Lighter scope than =daily-prep.org='s triage section.
+ - Triggers: "do a triage intake", "triage intake", "what's new?", "what's new since I last checked", "do a sweep", "check email, calendar, and PRs"
+ - Source adapters: =triage-intake.*.org= (=personal-gmail=, =personal-calendar=, =cmail=, =github-prs= ship general; projects add their own). Not independently triggerable — the engine loads them; "run the triage-intake workflow" always routes to the engine, never a plugin.
- =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.
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 8aa420c..2371d11 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -158,12 +158,14 @@ 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).
+*Engine + plugin naming convention.* A workflow file may be an *engine* that owns *source plugins*. The convention is dot-delimited: =engine-name.org= is the engine; =engine-name.plugin-name.org= is a plugin of it. The first dot after the engine name is the engine/plugin boundary; hyphens stay *inside* a segment (=triage-intake.personal-gmail.org= is engine =triage-intake=, plugin =personal-gmail=). The glob =engine-name.*.org= matches the plugins but not the engine (the engine has no second dot-segment), so an engine never loads itself. Deeper dots (=engine-name.plugin-name.sub-name.org=) are reserved for sub-adapters — unused for now, accommodated at no cost. This generalizes: any future engine =foo.org= owns =foo.*.org= plugins. Plugins are not independently triggerable — they have no index entry, and a "run the [engine] workflow" request always routes to the engine, never a plugin.
+
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.
+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, surface the mismatch and offer to update INDEX.org. If a file in the directory has no index entry, first test whether it's a *source plugin* — any file matching =<indexed-engine>.*.org= (a second dot-segment after an indexed engine name) belongs to that engine and is correctly absent from the index; do not flag it. Flag only genuine orphans: a non-plugin =.org= with no entry. Don't silently route around real drift.
+4. *Match the request* against the index trigger phrases first, then against project-workflow filenames if no index hit. A request matching an engine routes to =engine-name.org=, never to one of its =engine-name.plugin-name.org= plugins — the engine loads its own plugins at run time.
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.
diff --git a/claude-templates/.ai/workflows/triage-intake.cmail.org b/claude-templates/.ai/workflows/triage-intake.cmail.org
new file mode 100644
index 0000000..d818c72
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.cmail.org
@@ -0,0 +1,53 @@
+#+TITLE: Triage Intake — cmail (Proton) Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+
+* Source: cmail
+:PROPERTIES:
+:ORDER: 25
+:ENABLED: test -f .ai/scripts/cmail-action.py
+:ANCHOR: none
+:SUBAGENT_OVER: 50
+:END:
+
+** Scan
+
+Proton (=c@cjennings.net=) via the bridge script. =ANCHOR: none= because this reports live IMAP unread *state*, not a since-window — the engine substitutes no cutoff. Phase B uses the anchor only to flag which of the current unread arrived since last check.
+
+#+begin_src bash
+python3 .ai/scripts/cmail-action.py list-unread
+#+end_src
+
+JSON output, keyed by UID. The script ignores messages already flagged =\Deleted= (those are pending-flush on the next Proton sync), so the list is the genuinely-live unread set.
+
+** Classify
+
+Bias: *trash-leaning*, like personal Gmail — cmail catches a lot of forwarded noise.
+
+- *Noise-trash:* newsletters, marketing, social, automated alerts.
+- *Noise-keep:* receipts, statements.
+- *FYI:* substantive mail, no action owed.
+- *Action:* an explicit ask or reply owed. Flag "new since last check" by comparing the message date against the engine's anchor.
+
+** Render
+
+#+begin_example
+**cmail (Proton) — N unread.** <one-line classification summary>
+- Action: <items, if any>
+- FYI: <items, if any>
+- Noise: N trash candidates, M keep
+#+end_example
+
+Omit if zero unread.
+
+** Actions
+
+All take one or more UIDs (from the =list-unread= JSON):
+
+- mark-read :: =python3 .ai/scripts/cmail-action.py mark-read <uid>=
+- star :: =python3 .ai/scripts/cmail-action.py star <uid>=
+- unstar :: =python3 .ai/scripts/cmail-action.py unstar <uid>=
+- trash :: =python3 .ai/scripts/cmail-action.py trash <uid>= (flags =\Deleted=; flushed on next Proton sync)
diff --git a/claude-templates/.ai/workflows/triage-intake.github-prs.org b/claude-templates/.ai/workflows/triage-intake.github-prs.org
new file mode 100644
index 0000000..c1bc796
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.github-prs.org
@@ -0,0 +1,71 @@
+#+TITLE: Triage Intake — Personal GitHub PRs Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+#
+# This is the GENERAL (github.com) PR source. A work project on GitHub
+# Enterprise declares its own triage-intake.<work>-prs.org plugin with
+# --hostname and its own repos/conventions; it does not edit this file.
+
+* Source: github-prs
+:PROPERTIES:
+:ORDER: 40
+:ENABLED: command -v gh && gh auth status
+:ANCHOR: iso8601
+:SUBAGENT_OVER: 40
+:END:
+
+** Scan
+
+Open PRs needing Craig's attention across his personal GitHub projects. Two queries — PRs returns *state*, so the anchor is advisory (the engine substitutes =<anchor-iso8601>= into the optional =--updated= filter for recency; open-PR state is the real signal):
+
+#+begin_src bash
+# PRs Craig opened
+gh search prs --author=@me --state=open \
+ --json number,title,repository,state,isDraft,updatedAt,url
+
+# PRs awaiting Craig's review
+gh search prs --review-requested=@me --state=open \
+ --json number,title,repository,state,isDraft,updatedAt,url
+#+end_src
+
+=@me= resolves to the authenticated personal GitHub account, so no username is hardcoded. Add =--owner <account-or-org>= to scope to specific personal projects when the full-account search is too broad.
+
+=gh search prs= can't return =reviewDecision= or CI status. For the handful that survive Phase B as Action candidates, drill in per-PR:
+
+#+begin_src bash
+gh pr view <N> --repo <repo> --json reviewDecision,statusCheckRollup,mergeable
+#+end_src
+
+** Classify
+
+Action items, in priority order:
+
+- *Craig's PRs that are mergeable* — approved, or no review required on a solo project, with CI green and no outstanding CHANGES_REQUESTED.
+- *Craig's PRs with CHANGES_REQUESTED* — needs his response: rework and re-request, or push back with reasoning.
+- *PRs awaiting Craig's review* — review requested from him, or new commits since his last review on a PR he's reviewing.
+- *@mentions on any PR* — explicit ask.
+
+FYI: approval landings with no remaining action, merge events on PRs already shipped, comments without questions.
+
+Noise: stale draft updates, bot/CI status pings.
+
+** Render
+
+#+begin_example
+**PRs (<repo or "personal">) — N open.**
+- Mergeable now: <list>
+- Changes requested on yours: <list>
+- Review owed: <list>
+#+end_example
+
+Omit if nothing open needs attention.
+
+** Actions
+
+- merge :: =gh pr merge <N> --repo <repo> --squash --delete-branch= when no other PR stacks on the branch (verify via =gh pr list --repo <repo> --search "base:<branch-name>"=). If something stacks, =--squash= without =--delete-branch= and rebase the dependent PR afterward.
+- comment :: =gh pr comment <N> --repo <repo>=
+- review :: =gh pr review <N> --repo <repo>= (per Craig's call — approve / request-changes / comment)
+- branch-clean :: =git push origin --delete <branch>= when the merge happened earlier and only branch deletion remains.
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index 02e36e8..58b6709 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -1,126 +1,283 @@
-#+TITLE: Triage-Intake Workflow
+#+TITLE: Triage Intake Workflow (Engine)
#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-05-11
+#+DATE: 2026-05-01
* 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.
+Lightweight, between-meetings sweep across whatever sources are plugged in — email, calendar, chat, open PRs, ticketing. Classifies what came in since the last check (Action / FYI / Noise-keep / Noise-trash), produces a single synthesized summary, and offers to execute the routine actions (trash, mark-read, star, respond, merge, attachment fetch).
-* When to Use
+Think of it as the ER intake queue: every new message, invite, and PR notification is a "patient" walking through the door. This workflow is the triage nurse looking at the queue and telling Craig what needs attention now, what's just FYI, and what can be cleared.
-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"
+*This file is the engine.* It carries no sources of its own. Every source it scans comes from a *source plugin* — a =triage-intake.<source>.org= file the engine loads at Phase 0. The engine is source-agnostic and project-agnostic; the project- and account-specific knowledge lives entirely in the plugins. To add a source, drop a plugin file. To change one, edit its plugin. Never wire a source into this file.
-* The Workflow
+Distinct from =daily-prep.org=:
+- *daily-prep* — heavier, once daily, builds the day's plan + standup brief + meeting prep + time blocks.
+- *triage-intake* — fast, repeatable, just answers "what's new since last check?"
-** Phase 1: Fan-out (one parallel batch)
+* When to Use This Workflow
-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.
+Trigger phrases:
-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.
+- "Run a triage-intake"
+- "Triage intake"
+- "What's new" / "What's new since I last checked"
+- "Do a sweep" / "Do a triage sweep"
+- "Check email, calendar, and PRs"
-** Phase 2: Synthesize + Linear Dev-Review sweep
+Typical timing:
-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:
+- Between meetings (1-2 minute glance)
+- After a long focused-work block
+- Before context-switching to a new task
+- When ambient anxiety about "did I miss something?" creeps in
-- *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.
+Do *not* use when running daily-prep — daily-prep already does this as Phase 3.
-*** Linear Dev-Review sweep
+* Source Plugin Contract
-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:
+A source plugin is a file named =triage-intake.<source>.org=. The first dot after =triage-intake= is the engine/plugin boundary; the segment after it is the source id. Hyphens stay *inside* a segment (=triage-intake.personal-gmail.org= is engine =triage-intake=, source =personal-gmail=). Deeper dots (=triage-intake.<source>.<sub>.org=) are reserved for sub-adapters — YAGNI for now, but the namespace accommodates them at no cost.
+
+A plugin file declares exactly one source through a fixed shape:
+
+*Property drawer* on the top-level =* Source:= heading:
+- =ORDER= — integer. Output ordering in the per-source breakdown (lower = earlier).
+- =ENABLED= — the precondition the engine evaluates before loading the source. The source is skipped — *with an announced reason* — when it's false. Forms: =always=, a shell test (=command -v gh && gh auth status=), or =mcp <server> present=.
+- =ANCHOR= — the cutoff format this source consumes: =epoch=, =iso8601=, =day=, or =none= (state-based source with no since-window — e.g. live IMAP unread, or open-PR state). The engine computes the anchor once and substitutes it in the requested format.
+- =SUBAGENT_OVER= — integer. If the scan is expected to return more than this many items, dispatch a subagent for the source so its raw output stays out of main context. The subagent applies Phase B and returns buckets only, not the raw list.
+
+*Body sections:*
+- =** Scan= — the command(s) that fetch new/unread items since =<anchor>=, emitting raw items.
+- =** Classify= — the source's per-bucket bias and noise patterns. *Deltas* from the engine's shared four-bucket model below, not a re-derivation.
+- =** Render= — the source's block in the Phase C summary. "Omit if empty."
+- =** Actions= — the executable state-changes, one verb per line: =verb :: command template (parameterized by item id)=.
+
+Template:
+
+#+begin_example
+* Source: <id>
+:PROPERTIES:
+:ORDER: <n>
+:ENABLED: <precondition>
+:ANCHOR: epoch | iso8601 | day | none
+:SUBAGENT_OVER: <n>
+:END:
+
+** Scan
+<command(s) that fetch new/unread items since <anchor>>
+
+** Classify
+<bias + noise patterns; deltas from the shared four-bucket model>
+
+** Render
+"<Source label> — N <unit>" block; omit if empty.
+
+** Actions
+- <verb> :: <command, parameterized by <id>>
+#+end_example
+
+* Phase 0 — Load source plugins (MANDATORY — do not skip)
+
+The engine has no sources baked in. It discovers them by globbing *two* directories, and you MUST glob *both*:
+
+#+begin_src bash
+ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2>/dev/null
+#+end_src
+
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, personal GitHub PRs).
+- =.ai/project-workflows/triage-intake.*.org= — *PROJECT-SPECIFIC* source plugins, never synced, owned by this project (e.g. a work project's Linear, work Gmail, work Slack, enterprise-GitHub PRs).
+
+⚠ *THE #1 FAILURE MODE — read this twice.* Globbing only =.ai/workflows/= and silently missing every project plugin. If you skip =.ai/project-workflows/=, the sweep runs with *half its sources* and Craig never learns what it dropped — the omission is invisible, because a missing source looks identical to a quiet source in the output. There is no error, no empty block, no warning. The sweep just lies by omission. *Glob both directories. Always.*
+
+The glob exclude is automatic: =triage-intake.*.org= matches the plugins but not this engine file (=triage-intake.org= has no second dot-segment), so the engine never loads itself.
+
+After globbing, for each plugin file:
+1. Read it.
+2. Evaluate its =ENABLED= precondition. If false, *announce the skip with its reason* ("skipping linear — mcp__linear not present") and move on.
+3. The surviving set is the source list for Phases A-D.
+
+*Announce the loaded set before scanning* so the omission can't hide:
+
+#+begin_example
+Loaded 5 source plugins:
+ general: personal-gmail, personal-calendar, cmail, github-prs
+ project: deepsat-gmail
+ skipped: linear (mcp__linear not present)
+#+end_example
+
+If the project directory glob returns nothing, say so explicitly ("no project plugins in .ai/project-workflows/") rather than staying silent — silence is indistinguishable from forgetting to look.
+
+* Anchor: Since When?
+
+The workflow needs a "scan since" timestamp. Resolution order:
+
+1. *Sentinel file content:* first whitespace-delimited token in =.ai/last-triage-intake= is the Phase A scan-kickoff epoch from the most recent successful run (see "Capture the Phase A timestamp" below). Most accurate.
+2. *Sentinel file mtime* (back-compat): if the file exists but is empty, read its mtime — that's the older mtime-based convention that pre-dates the content-based change. Still accurate on the machine that wrote it.
+3. *Most recent prep doc:* if no sentinel content or readable mtime, anchor on the latest =inbox/YYYY-MM-DD-daily-prep.org= or =daily-prep/YYYY-MM-DD-daily-prep.org= mtime.
+4. *Most recent session file:* if none of the above, anchor on the most recent =.ai/sessions/= file's mtime.
+5. *Session start:* fall back to the current session's start time. Last resort.
+
+The engine computes the anchor *once* and exposes it in every format a plugin might request (=epoch=, =iso8601=, =day=). Each plugin's =ANCHOR= field says which it consumes; the engine substitutes that form into the plugin's =<anchor>= placeholder. Sources with =ANCHOR: none= are state-based (live unread, open-PR state) and get no cutoff substituted — they report current state, and Phase B uses the anchor only to flag what's *new since* last check.
+
+** Capture the Phase A timestamp
+
+Just before issuing the Phase A batch, capture the current epoch seconds:
+
+#+begin_src bash
+PHASE_A_TS=$(date +%s)
+#+end_src
+
+Hold this value through Phases B, C, and D. At end of run, *write* the captured timestamp into the sentinel's content (not its mtime):
+
+#+begin_src bash
+echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake
+#+end_src
+
+The file ends up with a single line like =1778683109 2026-05-13 09:38:29 -0500= — epoch first (machine-readable, parsed by reading the first token), human-readable timestamp second.
+
+*Why content, not mtime:* the sentinel is checked into git. Git tracks content, not mtime, so an mtime-based sentinel is per-machine: one machine's anchor stays on that machine; a fresh clone gets the file but the mtime is whenever the clone happened, not the actual triage time. Writing the epoch as content means the anchor travels with the repo and stays accurate after a fetch + pull on any machine.
+
+*Why Phase A and not end-of-run:* Phase A runs at one moment, but Phases B-D may take 5-30 minutes. Items posted to any source /during/ Phases B-D land between the Phase A scan time and the eventual end-of-run time. If the sentinel were set to the end-of-run time, those items would silently fall through the cracks: the next triage's Phase A would skip the gap window and never see them. Anchoring the sentinel to Phase A's scan time guarantees the next run's window starts where this run's window ended, with zero gap.
+
+** Reading the sentinel
+
+When the workflow needs the anchor at the start of a new run:
#+begin_src bash
-gh pr list --search "head:<gitBranchName>" --state all \
- --json number,state,headRefName,mergedAt,title
+# Content-first, mtime-fallback.
+ANCHOR_EPOCH=$(awk 'NR==1 {print $1; exit}' .ai/last-triage-intake 2>/dev/null)
+if [ -z "$ANCHOR_EPOCH" ] && [ -f .ai/last-triage-intake ]; then
+ ANCHOR_EPOCH=$(stat -c %Y .ai/last-triage-intake)
+fi
#+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 both fail, fall through to the resolution order above (prep doc → session file → session start).
+
+* Approach: Phases A → D
+
+** Phase A: Fan-out (one parallel batch)
+
+Issue every enabled source's =Scan= command in a single message, with the anchor substituted in each source's declared format. They have no dependencies and benefit from running concurrently.
+
+Per-source subagent escalation: if a source's scan is expected to return more than its =SUBAGENT_OVER= count (e.g. personal Gmail after a multi-day gap), dispatch a subagent for that source. The subagent applies Phase B classification and returns the synthesized buckets, not the raw item list.
+
+** Phase B: Classify per source (shared four-bucket model)
+
+Every item lands in one bucket. Plugins refine these with source-specific bias and noise patterns in their =Classify= section; they do not redefine the buckets.
+
+- *Action* — needs Craig to do something: an explicit ask, a decision needed, blocked-on-Craig, a mergeable PR, an invite needing a response, a deadline inside 48h.
+- *FYI* — substantive context worth seeing, but no action owed.
+- *Noise-keep* — low value but worth retaining (audit trail, receipts).
+- *Noise-trash* — safe to discard: newsletters, marketing, social digests, bot pings, redundant aggregator digests, wrong-recipient mail, past-event artifacts.
+
+Per-source bias (a work email account leans keep for audit value; a personal account leans trash on high noise volume) lives in each plugin's =Classify= section. Read it from there; don't re-derive it here.
+
+** Phase C: Synthesize a single summary
+
+One markdown summary surfaced inline to Craig. Order:
+
+1. *Top signals to act on* — bullet list of 3-7 items, ordered by urgency, *Action only*. Each bullet links to the source (permalink, thread URL, PR number).
+2. *Per-source breakdown* — one short section per *loaded* source, in =ORDER=, using that plugin's =Render= shape: Action items detailed, FYI items as a short list, Noise as a tally only ("Noise: 12 trash candidates, 4 keep, 0 starred").
+3. *Suggested actions* — explicit list of state changes Craig could take this run (trash these N messages, mark-read these M, star this Action item, respond to this invite, merge PRs #X and #Y, etc.).
-If a Dev-Review ticket's PR is *merged*, propose a status move:
+Format target: scannable in 30 seconds, full read in 2 minutes. Don't pad. If a source returned zero items, write "Calendar — quiet" or "PRs — nothing new since 14:30 yesterday."
-- *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.
+*** Sub-step: write each Action item into =todo.org= as its own =:quick:= task
-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.
+After surfacing the summary inline, append every Action item — regardless of source — to =todo.org= as its own top-level =** TODO= heading carrying the =:quick:= tag plus =:reactive:= and any relevant person/entity tag.
-Skip the sweep entirely if the project doesn't use Linear (personal projects, the rulesets repo, etc.).
+Each Action item is one task. Don't group items by source under =** Email Response=, =** PR Review=, etc. sub-headings. Each response is its own filterable task so Craig can re-prioritize, =SCHEDULE:= / =DEADLINE:=, or tag individually.
-** Phase 3: Mark-as-read (at the end — not as you go)
+Format:
-Clear unread state. Default behavior unless the user explicitly says "leave this unread."
+#+begin_example
+** TODO [#B] Merge PR #42 on archsetup (approved, CI green) — [[https://github.com/<user>/archsetup/pull/42][PR #42]] :quick:reactive:
+** TODO [#B] Respond to the 2pm reschedule invite from Dana :quick:reactive:
+** TODO [#B] Reply to the contract-terms email thread :quick:reactive:
+#+end_example
-*** Email — every unread INBOX message, all three accounts
+Rules:
-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."
+- Heading is plain prose. Lead with the verb (Read / Re-review / Reply / Respond / Address / Merge / Schedule).
+- Priority: default =[#B]= for fresh reactive items. Bump to =[#A]= only if blocking someone or a deadline lands inside 7 days.
+- Tags: always =:quick:= + =:reactive:=. Add person/entity tags when the dependency is sharp.
+- Link the source in the heading when it has a URL (GitHub PR, mail thread, chat permalink). Use org's =[[url][label]]= form so the heading stays clickable in Emacs.
+- *Record the source locator in the task body* so a reply can be routed back to where the request came from — the channel + thread id for chat, the repo + PR number, the message id for mail. The general rule: a reply goes back to the *origin* of the request, not a fixed notification channel. (Project plugins may add stricter routing rules in their own files.)
+- Placement: append at end of =* Work Open Work= (just before =* Work Incubate=) unless the project's =todo.org= has a designated triage section near the top (=* Triage= or =* Inbox=).
-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='l'
- # --fields='l' is the file location (full path). Don't use 'p' — in current
- # mu that's the priority field and returns "normal" for every row, which
- # makes the flag manager error on every path (caught 2026-05-22).
- #+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.
+This sub-step makes triage-intake's findings *persist* in =todo.org= instead of evaporating after the inline summary.
-*** Slack — every conversation surfaced
+** Phase D: Execute actions on confirmation
-=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.
+Wait for Craig's go-ahead before running any state changes. Default to single-confirmation for the whole batch ("yes" → run everything proposed). Craig may also pick a subset ("trash personal but hold the work account") or hand back a different plan ("trash all but star the expense thread and queue PR merges for after lunch").
-*** Skip
+Each action dispatches to the owning source plugin's =Actions= verb (trash, mark-read, star, respond, merge, comment, attachment-fetch). The engine doesn't hardcode action commands — it reads them from the loaded plugins. Read each plugin's =Actions= section for the exact command.
-Linear, GitHub PRs, and calendars — none has a clean "mark this read" concept that maps to inbox hygiene.
+After actions complete, write the Phase A capture into the sentinel's *content* (see "Capture the Phase A timestamp"): =echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake=. Do not use plain =touch= (writes mtime to /now/ and strands items posted between Phase A and end of run) and do not use =touch -d "@$PHASE_A_TS"= (correct timestamp but mtime is per-machine — won't survive a fresh clone or cross-machine sync).
-** Phase 4: Report
+*Do not close the workflow yet.* See Exit Criteria below.
-One concise summary back to the user:
+** Exit Criteria
-- *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.
+The workflow stays open until Craig has *explicitly* either:
-* Principles
+1. *Confirmed* that the executed actions are sufficient and nothing more is needed this round, or
+2. *Handed back a different plan* (e.g., "actually hold the PR merges, address #131 first").
-- *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.
+A successful Phase D run is *not* an exit signal. After the action batch returns, surface what shipped and wait. Don't volunteer "done" or "all set" — those are exit-claim phrases that pre-empt Craig's call. Use a status report ("17 actions succeeded, sentinel written at 12:19") and stop.
+
+If Craig has been silent for a while after Phase D and the surface looks closed-out, *ask*: "Anything else on this triage, or are we good to close out?" Don't auto-terminate.
+
+This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in.
+
+* Output Template
+
+The summary follows this shape (omit any source that genuinely returned nothing; render one block per *loaded* source in =ORDER=, using each plugin's =Render= shape):
+
+#+begin_example
+**Anchor:** <previous run timestamp> → now (<elapsed> elapsed)
+**Loaded:** <general plugins> + <project plugins> (skipped: <disabled, with reason>)
+
+**Top signals to act on:**
+1. <terse Action description with link>
+2. ...
+
+<one block per loaded source, in ORDER — see each plugin's Render>
+
+**Suggested actions:**
+- Trash N noise items
+- Mark-read M keep items
+- Respond to <invite>
+- Merge PRs #X and #Y
+- ...
+#+end_example
+
+Order matters: top-signals first because that's what Craig reads in 30 seconds between meetings. Per-source detail second. Suggested actions last because they require a decision.
+
+* Common Mistakes
+
+1. *Globbing only =.ai/workflows/= and missing the project plugins.* The single most damaging failure mode — the sweep runs with half its sources and the omission is invisible (a missing source looks identical to a quiet one). Phase 0 globs *both* =.ai/workflows/triage-intake.*.org= and =.ai/project-workflows/triage-intake.*.org=, every run, and announces the loaded set.
+2. *Running Phase A sequentially.* Send every enabled source's scan in one message — the whole point is parallelism.
+3. *Wiring a source into the engine.* Sources live in plugin files, never here. If you find yourself editing this file to add an account, repo, or channel, stop — write or edit a =triage-intake.<source>.org= plugin instead.
+4. *Executing actions without explicit confirmation.* Phase D runs only after Craig says "yes" or picks a subset.
+5. *Forgetting to set the sentinel at the end.* Without it, the next run re-scans the same window.
+6. *Using mtime instead of content for the sentinel.* Plain =touch= writes /now/ to mtime, stranding items posted between Phase A and end of run. =touch -d "@$PHASE_A_TS"= fixes the time but mtime is per-machine — git tracks content, not metadata, so the anchor doesn't survive a clone or cross-machine sync. Always write the epoch into the file's *content*.
+7. *Running this alongside daily-prep.* Daily-prep already does this as Phase 3 — don't duplicate.
+8. *Mixing Action and FYI in the top-signals list.* Top signals = Action only. FYI lives in the per-source detail.
* 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.).
+Update the engine as the orchestration pattern evolves; update a plugin as its source evolves. Source-specific learnings belong in the plugin's own file, not here.
+
+** Updates and Learnings
+
+*** 2026-05-01: Initial creation
+Extracted from daily-prep's Phase 3 pattern as a standalone, lightweight, between-meetings sweep.
+
+*** 2026-05-07: Anchor the sentinel to Phase A scan time, not run-end time
+Gap-window bug: a run had Phase A fire at 13:35 and the sentinel set at 15:04, so an item posted at 14:20 would be skipped by the next run (the sentinel claimed everything before 15:04 was scanned when Phase A only reached 13:35). Fix: capture =PHASE_A_TS= just before Phase A, hold it through B-D, write it to the sentinel at end of run. The sentinel means "everything before this timestamp has been scanned," the only invariant that prevents items falling through the cracks.
+
+*** 2026-05-13: Move the sentinel from mtime to content (cross-machine survivability)
+The sentinel is checked into git, but git tracks content, not mtime — so an mtime anchor is per-machine. Fix: write the captured epoch into the sentinel's content (=EPOCH ISO-8601=), read with =awk 'NR==1 {print $1}'=, mtime as back-compat fallback.
+
+*** 2026-05-26: Refactor into engine + source plugins
+Split the monolithic workflow into a source-agnostic engine (this file) and per-source plugins named =triage-intake.<source>.org=. The engine carries the anchor/sentinel logic, the four-bucket model, the Phase A-D orchestration, the todo.org persistence convention, and the exit criteria. Each source's scan/classify/render/action knowledge moved to its own plugin. General plugins (personal-gmail, personal-calendar, cmail, github-prs) live in =.ai/workflows/= and are template-synced; project-specific plugins (a work project's Linear, work Gmail, work Slack, enterprise PRs) live in the project's =.ai/project-workflows/= and are never synced. Phase 0 globs *both* directories — the loud requirement, because missing the project dir silently halves the sweep. Naming convention: first dot is the engine/plugin boundary, deeper dots reserved for sub-adapters. This removed all DeepSat/Linear specifics from the engine; they become work-project plugins.
diff --git a/claude-templates/.ai/workflows/triage-intake.personal-calendar.org b/claude-templates/.ai/workflows/triage-intake.personal-calendar.org
new file mode 100644
index 0000000..bf7d543
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.personal-calendar.org
@@ -0,0 +1,53 @@
+#+TITLE: Triage Intake — Personal Calendar Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+
+* Source: personal-calendar
+:PROPERTIES:
+:ORDER: 30
+:ENABLED: mcp google-calendar present
+:ANCHOR: iso8601
+:SUBAGENT_OVER: 40
+:END:
+
+** Scan
+
+Calendar isn't an unread queue — it's two signals: what's *coming up soon* and what *changed since last check*. Pull the near-horizon window for the personal account, then let Classify split it by the anchor.
+
+#+begin_src text
+mcp__google-calendar__list-events account="personal" timeMin="<now-iso8601>" timeMax="<now+36h-iso8601>" singleEvents=true orderBy="startTime"
+#+end_src
+
+The event objects carry =created=, =updated=, and the attendee =responseStatus=. The engine supplies =<anchor-iso8601>= so Classify can flag events whose =updated= is after the anchor as "new/changed since last check." (=list-events= filters by start-time window, not by updated-since, so the change detection happens in Classify, not the query.)
+
+** Classify
+
+Calendar leans *FYI* — most upcoming events are just awareness. The Action cases are narrow:
+
+- *Action:*
+ - A new invite with =responseStatus=needsAction= (Craig hasn't replied).
+ - A reschedule or cancellation of an event Craig had — =updated= after the anchor and the time/status changed.
+ - An event starting within ~2h that Craig might have lost track of.
+- *FYI:* upcoming events today/tomorrow, unchanged since last check.
+- *Noise-keep / trash:* declined events, all-day informational banners, automated holiday entries — tally only, no action.
+
+Surface "changed since last check" prominently — a reschedule that slips by is the expensive miss.
+
+** Render
+
+#+begin_example
+**Calendar (personal) — N upcoming, M changed since last check.**
+- Action: <invites needing a response, reschedules, imminent events>
+- Upcoming: <today/tomorrow events, terse>
+#+end_example
+
+Omit the block if nothing upcoming and nothing changed.
+
+** Actions
+
+- respond :: =mcp__google-calendar__respond-to-event= account="personal" eventId=<event-id> responseStatus=<accepted|declined|tentative>
+
+Read-only otherwise — triage surfaces calendar state; it doesn't create or move events (that's the add/edit-calendar-event workflows).
diff --git a/claude-templates/.ai/workflows/triage-intake.personal-gmail.org b/claude-templates/.ai/workflows/triage-intake.personal-gmail.org
new file mode 100644
index 0000000..aa0554d
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.personal-gmail.org
@@ -0,0 +1,53 @@
+#+TITLE: Triage Intake — Personal Gmail Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-26
+
+# Source plugin for the triage-intake engine. See triage-intake.org for the
+# contract and the Phase A-D orchestration. This file declares ONE source.
+
+* Source: personal-gmail
+:PROPERTIES:
+:ORDER: 20
+:ENABLED: mcp google-docs-personal present
+:ANCHOR: epoch
+:SUBAGENT_OVER: 50
+:END:
+
+** Scan
+
+Personal Gmail unread in the inbox since the anchor:
+
+#+begin_src text
+mcp__google-docs-personal__listMessages q="is:unread in:inbox after:<anchor-epoch>" maxResults=100
+#+end_src
+
+⚠ *Express the cutoff as the literal UNIX epoch* — =after:1778856990=, not =after:YYYY/MM/DD=. Gmail's =after:YYYY/MM/DD= operator only supports day resolution; the =YYYY/MM/DD HH:MM:SS= form is NOT valid syntax — Gmail parses the space as a term separator, treats =HH:MM:SS= as a search term that never matches, and returns 0 results, silently masking unread mail. The engine supplies =<anchor-epoch>= because this source declares =ANCHOR: epoch=.
+
+⚠ *Do NOT add =-category:promotions -category:social=.* That filter masked 67 promo+social messages across two runs (2026-05-04, 2026-05-06), both needing a follow-up sweep. Pull the full unfiltered set; the trash-leaning bias in Classify handles promotions and social directly.
+
+** Classify
+
+Bias: *trash-leaning* — personal Gmail is high noise volume.
+
+- *Noise-trash:* newsletters, Substacks, retail/SaaS marketing, social digests, redundant aggregator digests (Notion/Miro daily), wrong-recipient mail, past-event calendar artifacts.
+- *Noise-keep:* receipts, order confirmations, statements — low value but worth the audit trail.
+- *FYI:* substantive personal mail with no action owed.
+- *Action:* an explicit ask, a reply owed, a time-sensitive personal matter.
+
+** Render
+
+#+begin_example
+**Personal Gmail — N unread.** <one-line classification summary>
+- Action: <items, if any, with thread links>
+- FYI: <items, if any>
+- Noise: N trash candidates, M keep
+#+end_example
+
+Omit the block if zero unread.
+
+** Actions
+
+- trash :: =mcp__google-docs-personal__trashMessage= id=<message-id> (recoverable from Gmail Trash for 30 days)
+- mark-read :: =mcp__google-docs-personal__modifyMessageLabels= id=<message-id> removeLabelIds=["UNREAD"]
+- star+read :: =mcp__google-docs-personal__modifyMessageLabels= id=<message-id> addLabelIds=["STARRED"] removeLabelIds=["UNREAD"]
+- attach-fetch:: =.ai/scripts/gmail-fetch-attachments.py --profile personal --message-id <message-id> --output-dir <PATH>=