1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
|
#+TITLE: Triage Intake Workflow (Engine)
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-01
* Summary
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).
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.
*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.
*Which sources a project pulls is a per-project choice.* A *project-specific* plugin (=.ai/project-workflows/triage-intake.*.org=, never synced) is active by presence — dropping it is the declaration. A *general* plugin (=.ai/workflows/triage-intake.*.org=, template-synced into every project — personal Gmail, cmail, calendar, Telegram, GitHub PRs) is active only when the project names its basename in a =:TRIAGE_SOURCES:= line in =.ai/notes.org= Workflow State (space-separated basenames, e.g. =:TRIAGE_SOURCES: personal-gmail cmail=). A project that declares nothing and owns no project plugin pulls nothing. This is the Phase 0 activation gate — presence is capability, the declaration is activation (see =docs/specs/2026-07-20-triage-source-activation-spec.org=).
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?"
Quick contract — what it does: fans out across source plugins, classifies every item into Action / FYI / Noise-keep / Noise-trash, surfaces one three-section digest (==TASKS== / ==FYI== / ==MISC==) with a two-option close offer, then *closes by default*: files each TASKS item to =todo.org= as a =:quick:reactive:= task, runs the star/mark-read/trash hygiene on every scanned account, advances the sentinel, and tears down anything it started. The close runs unless Craig explicitly holds it.
** When to Use This Workflow
Trigger phrases:
- "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"
Typical timing:
- 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
Do *not* use when running daily-prep — daily-prep already does this as Phase 3.
Also runs unattended as sentry's triage pass (=sentry.org=, pass 3): sentry invokes this engine under its no-approvals contract, where destructive actions (deleting, archiving, sending) queue for the morning-approval review instead of firing. The trigger phrases above are unchanged — a manual "triage intake" always routes here directly.
* Execution
** 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, Telegram, 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. *Activation gate.* A *general* plugin (from =.ai/workflows/=, template-synced into every project) is active only if its basename appears in the project's =:TRIAGE_SOURCES:= declaration (=.ai/notes.org= Workflow State — a space-separated list of source basenames). If it isn't declared, it is *inactive*: announce it ("inactive: personal-gmail — not in :TRIAGE_SOURCES:") and skip it. A *project-specific* plugin (from =.ai/project-workflows/=, never synced) is always active — dropping it there is itself the per-project declaration. This is what stops the synced general plugins from self-activating in projects that aren't triage targets: presence is capability, the declaration is activation (see =docs/specs/2026-07-20-triage-source-activation-spec.org=). An absent or empty =:TRIAGE_SOURCES:= means no general sources are active; a project with no declaration and no project plugin has no active sources, so triage no-ops there.
2. Read it.
3. Evaluate its =ENABLED= precondition. If false, *announce the skip with its reason* ("skipping linear — mcp__linear not present") and move on.
4. The surviving set — active and enabled — is the source list for Phases A-D.
*Announce the loaded set before scanning* so the omission can't hide — inactive (undeclared) plugins are named too, so a general plugin left out of =:TRIAGE_SOURCES:= is a visible choice, not a silent drop:
#+begin_example
Loaded 2 source plugins (:TRIAGE_SOURCES: personal-gmail cmail):
general: personal-gmail, cmail
project: deepsat-gmail
inactive (undeclared): personal-calendar, github-prs, telegram
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.
** 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 — the three-section digest
One digest surfaced inline to Craig — notable items only, written as prose bullets a reader can absorb without knowing the source taxonomy. It is *not* a per-source roll-call; the plugins' =Render= shapes feed the classification, they are no longer displayed as blocks. (Format ratified by Craig 2026-07-18 after a sweep where the follow-up "summarize the notable items" digest was the report he actually wanted first.)
Order:
0. *Scan failures — first, loud, always.* Any loaded source whose scan failed, hung, was killed, or was skipped for an operational reason renders at the very top of the summary, before everything else:
#+begin_example
⚠ SCAN FAILED: <source> — <reason, one line> — <what's now unknown>
#+end_example
A failed scan is never folded into "quiet." Quiet means the scan ran and found nothing; a failure means the sweep is blind on that channel, and the reader must know which. The same applies to a precondition skip the user hasn't standing-approved (e.g. a messaging client that needs a temporary server spin-up): run the lifecycle or report the failure — don't silently narrow the sweep. Backlog banners (a plugin's pre-anchor-residue probe firing) render here too — loud, above the sections.
1. *==TASKS==* — every work item that needs Craig or his sign-off. Sorted in two groups: *solo-executable first* (see the solo test in Phase D), then the rest; within each group, priority order (blocking someone / deadline inside 48h first). Each item is one short prose bullet naming who, what, and why-now, with the source link or locator.
2. *==FYI==* — substantive work context worth seeing, no action owed. Priority order.
3. *==MISC==* — everything outside the project: personal mail, family, household, personal calendar. Priority order. An outside-project item that needs Craig still lands here (with its action-ness stated inline), not in TASKS — TASKS is the work queue. MISC items are *not* filed into this project's =todo.org=; they surface in the digest and are gone after the close unless Craig reroutes them to their owning project (the "and reroute" modifier, below).
A section with nothing in it is omitted. After the sections, the offer (see Phase D). The sweep's final line is always the run timestamp (=date "+%A %Y-%m-%d %H:%M %Z"=).
*Deltas only.* The digest reports what *changed* since the anchor: a new invite, a new/moved/cancelled calendar event, a new message needing attention. A source with no changes contributes nothing — no "Calendar — quiet", no "PRs — nothing new" roll-call. A sweep where nothing changed anywhere renders as a single line plus the timestamp:
#+begin_example
17:39 sweep: no changes
#+end_example
(Craig, 2026-06-11: "we only need to report if anything's changed when we do triage intake. did someone send me a new invite? did christine throw something on my calendar that wasn't there earlier? did someone cancel a meeting?")
Scan failures are the standing exception: a failed or skipped scan always renders loudly per point 0 above and is never folded into the no-change line — "no changes" is a claim about channels the sweep could actually see.
Format target: scannable in 30 seconds, full read in 2 minutes. Don't pad. The old long-form report (anchor line, per-source breakdown, itemized suggested-actions list) is available *on request* — it is no longer the default surface.
**** The offer — exactly this, right after the sections
#+begin_example
1. Close the triage — file todo.org tasks for the TASKS items, run the standard close
2. Close the triage and execute the solo TASKS now, after filing the rest
Append "and reroute" to either option to send the outside-project items to their owning projects.
Or tell me what you'd like handled differently.
#+end_example
Option 2 renders *only when solo-executable TASKS exist*. The reroute line renders only when MISC is non-empty. No other options, no itemized action menu — the close (Phase D) owns the routine hygiene.
*The reroute modifier.* "1 and reroute" / "2 and reroute" (or a bare "reroute" right after a close) means: in addition to the close, deliver every outside-project item the sweep surfaced to the project that owns it. Routing goes through =inbox-send= per the cross-project rule — a handoff into the owner's =inbox/=, never a direct write to a foreign =todo.org= — and the owner's own inbox processing files it by its conventions. This is the persistence path for MISC: without a reroute, MISC items are surfaced-only.
*** Phase D: Close — the default, not an option
*Closing the triage is the next action after the digest, no exceptions* — unless Craig explicitly says not to ("hold the triage", "don't close yet"). If his reply picks option 1 or 2, close per the option. If his reply is anything else — a question, a redirect, a new task — *close the triage first* (as option 1), then handle what he asked. An unclosed triage strands the noise unprocessed and the sentinel stale; Craig ruled 2026-07-18 that the close, including the mail hygiene on every scanned account, is unconditional.
The close, in order:
1. *File every TASKS item into =todo.org=* as its own =:quick:reactive:= task (format below). Dedupe against existing tasks first — fold into an existing task's body when one already covers the topic.
2. *Mail and message hygiene on every scanned account*: trash the Noise-trash set, mark-read the Noise-keep set, star what was flagged for keeping. This runs *without itemized confirmation* — the digest's tallies are the notice, and the actions dispatch through each plugin's =Actions= verbs. Trash is recoverable (Gmail 30-day trash; cmail =\Deleted= flag), which is what makes the no-confirmation batch safe.
3. *Clear unacked items* that the sweep found resolved.
4. *If option 2: execute the solo TASKS.* The solo test — mechanical, standing-approved, and producing *no prose under Craig's name*: calendar RSVPs, ticket-state moves the publishing overlay already authorizes, mark-read/star/trash. Anything that sends words as Craig (a Slack reply, an email, a PR comment, a Linear comment) is *never* solo — it stays a filed task and goes through the normal draft gate when worked. Destructive or hard-to-reverse actions beyond mail hygiene (branch deletes, PR merges) also stay confirm-gated per their plugins.
5. *If "and reroute": route the outside-project items.* For each MISC item (and any surfaced item this project doesn't own), send a handoff to the owning project's inbox: =inbox-send <project> --text "..."= carrying what it is, the source locator (message id, thread, event id), and why it routed. Resolve the owner against =inbox-send --list=; when ownership is ambiguous, ask before sending — a wrong-project handoff costs more than one question. Never write another project's =todo.org= directly (cross-project rule). Note in the close's status line which items went where.
6. *Advance the sentinel* — write the held 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).
7. *Tear down anything the sweep started* (e.g. the telegram lifecycle's leave-no-trace shutdown).
After the close, report one status line — what shipped, the sentinel time — and stop. The close *is* the exit; there is no separate confirmation loop.
**** Task-filing format (=todo.org=)
Append every TASKS item — regardless of source — as its own top-level =** TODO= heading carrying the =:quick:= tag plus =:reactive:= and any relevant person/entity tag.
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.
Format:
#+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
Rules:
- 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=).
The filing makes triage-intake's findings *persist* in =todo.org= instead of evaporating after the inline digest. Every close 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.
*** Exit Criteria
The close is the exit. Once the close completes (tasks filed, hygiene run, sentinel advanced, teardown done), report one status line — what shipped, the sentinel time — and stop. The old stay-open-until-confirmed loop is retired (Craig, 2026-07-18): the digest plus the two-option offer is the whole interaction, and the close runs by default. The only way the workflow stays open is Craig explicitly saying not to close.
*** KB capture (only if the sweep surfaced something durable)
If this sweep surfaced a durable, cross-project fact — a recurring pattern across sources, a reference pointer worth keeping, an environment gotcha — consider writing it to the agent KB as one =:agent:= node (see the best-practices node and =knowledge-base.md=; personal projects only, work never writes). One line of judgment, not a step: an all-quiet sweep surfaces nothing and writes nothing. Never blocking, never padded onto a no-signal run.
* Auto mode (unattended monitoring)
Auto mode is a self-running variant of the engine for when Craig is away from the desk but wants tight awareness — a loop that runs the standard sweep on a short interval, *accumulates* findings rather than mutating state, and hands Craig a gated checkpoint to commit the batch. It composes two things: the *delivery* (a =/loop= in the live session) and the *behavior* (accumulate-don't-mutate sweeps with a checkpoint). The one-shot run above is unchanged; auto mode is an additional way to run the same Phase 0 / A-D engine.
** Trigger and delivery
- "auto triage" / "auto triage-intake" / "watch the desk" / "monitor the triage" — start auto mode.
- Default interval *20 minutes*; Craig sets it.
Auto mode runs as a =/loop= in the *live session*, not a detached cron job:
#+begin_src
/loop 20m run an auto-mode triage-intake sweep per triage-intake.org
#+end_src
Running in the live session means MCP auth (Slack, Gmail, Linear) is inherited from the session — the headless-auth wall that blocks a detached cron run does not apply. A durable cross-session schedule is out of scope here; that belongs to the morning-ops orchestrator, which can later invoke auto mode's accumulate behavior as its triage limb. The close/stop commands below require a live session by design.
*** Phone delivery — push each signal sweep via =agent-text=
Auto mode exists for when Craig is away from the desk, so a sweep that surfaces something worth seeing is delivered to his phone, not just printed into a session he isn't watching. After a sweep that renders the full three sections — one with real deltas or an unacked-list change (see "End-of-sweep output" below) — send that same output to his phone over Signal with =agent-text=:
#+begin_src bash
agent-text "$SWEEP_SUMMARY"
#+end_src
The pushed text is the *fuller* three-section shape, not a terse one-liner: the per-source deltas, the responses-awaiting-acknowledgment list, and the timestamp, led by a ⚠ SCAN FAILED banner if any source failed.
*Signal-only — never on a quiet sweep.* An empty sweep (the =triage intake at HH:MM: nothing= heartbeat) does *not* push to the phone. Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=) governs the phone channel too, so the phone stays silent until a sweep has real signal. The in-session heartbeat still prints as proof the loop ran; the phone is reserved for something that actually needs Craig. (Craig's ruling, 2026-07-20: the higher-cost channel doesn't buzz with "nothing.")
If =agent-text= isn't on =PATH=, fall back to inline delivery and say so once.
*Reply polling is deferred.* The send half ships here; polling the phone for Craig's replies (the =phone-recv= half of the retired ntfy design) waits on the reply-correlation follow-up. With the Signal account linked on more than one device, a reply fans out to every device and neither knows which page it answers — that has to be resolved before auto mode reads replies back. Until then auto mode pushes but does not poll, and Craig acts on a pushed summary from wherever he picks it up.
** Preconditions and Close-out
Auto mode borrows the inbox monitor-mode gates (=inbox.org= monitor mode): do not start on a dirty worktree or a red test suite — a close's batch commit would otherwise sweep up unrelated changes — and leave the tree clean and green when the loop stops. Surface a blocker with inline numbered options per =interaction.md= and wait.
** A sweep: accumulate, don't mutate
Each sweep runs Phase 0 (load *both* plugin dirs — the loud requirement still holds) and Phases A-D's scan / classify / synthesize, but performs *none* of the normal run's mutations:
- Does NOT advance the sentinel. The scan window grows from the last *close* until the next close: every sweep scans from the existing sentinel up to now, so nothing between sweeps is dropped.
- Does NOT write =todo.org= Action tasks — accumulates them for the close.
- Does NOT take mail actions (trash / mark-read / star).
- Does NOT commit.
- DOES update an active daily-prep in Update mode and re-open it on change (per =daily-prep.org=).
- DOES report, deltas-only, with loud scan-failure banners (Phase C rules unchanged).
** End-of-sweep output — three sections, or one heartbeat
*Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=).* An *empty sweep* — no deltas since the previous sweep and no change to the awaiting-acknowledgment list — collapses to a single heartbeat line and nothing else: =triage intake at HH:MM: nothing= (HH:MM local, from =date=). Detection still runs in full (Phase 0 plus the A-D scan, against the session's inherited MCP auth); only the output collapses, so a long unattended run stops filling the session with identical "no changes" blocks. A sweep with real deltas or an unacked-list change prints the full three sections below, and — when away — pushes them to Craig's phone via =agent-text= (see "Phone delivery" above). The empty-sweep heartbeat is never pushed.
1. *Deltas* — what changed since the *previous sweep* (the standard Phase C summary scoped to the inter-sweep delta).
2. *Responses awaiting your acknowledgment* — every Slack reply, email, or message directed at Craig that he hasn't acknowledged or had the agent answer. A *running list carried forward across sweeps* until Craig acks each item or closes the triage. An away user's first need is "who's waiting to hear back from me," which a delta-only sweep loses the moment it scrolls past.
3. *Timestamp* — the current date, time, and timezone on the sweep's own final line, so an away reader sees how fresh the summary is without computing it. Print it on every sweep that prints these three sections. On an *empty* sweep there is no separate timestamp line — the heartbeat (=triage intake at HH:MM: nothing=) is itself the freshness stamp and the proof the loop ran. Generate it with:
#+begin_src bash
date "+%A %Y-%m-%d %H:%M:%S %Z (%z)"
#+end_src
** The unacked list — durable state
The awaiting-acknowledgment list lives in =.ai/triage-intake-unacked.org=, so it survives a session crash, a =/clear=, or a restart — the away-from-desk case auto mode exists for. It's project-local state, tracked the same way as the sentinel (=.ai/last-triage-intake=), created on first need.
Shape — one =** = heading per awaiting item:
#+begin_example
#+TITLE: Triage Intake — Responses Awaiting Acknowledgment
# Maintained by triage-intake auto mode. One heading per item; acked items are removed.
** Dana — 2pm reschedule invite
:PROPERTIES:
:SOURCE: personal-calendar
:LOCATOR: <event id or thread url — the dedupe key>
:SINCE: 2026-06-15 10:42
:END:
She's waiting on a yes/no to the move.
#+end_example
- *Add* — a sweep appends any new directed-at-Craig response not already listed, deduped on =LOCATOR=.
- *Carry forward* — every sweep re-renders the full list in its second section, whether or not it changed this sweep.
- *Ack* — "ack <item>" (e.g. "ack the Dana thread") removes that heading; "ack all" clears the list.
- *Close* — a close empties the list as part of processing (each item is either actioned or filed).
** Close and stop — the checkpoint
The mutations are gated behind two commands:
- *"close the triage"* — run the full close per Phase D: take the accumulated mail hygiene, file the accumulated TASKS items to =todo.org=, reroute if asked ("close the triage and reroute"), empty the unacked list, then *advance the sentinel* — capture the close run's Phase A timestamp, do the mutations, write that timestamp to =.ai/last-triage-intake= exactly as a normal run does (per "Capture the Phase A timestamp") — and commit + push the batch. Then *keep looping* (next sweep on the normal interval). This is the "flush the batch and carry on" checkpoint.
- *"stop the triage"* — the same close processing, then *stop the loop* and revert to manual (on-demand) triage.
A close is the only point auto mode advances the sentinel or commits. Between closes the engine state is untouched — that is what makes a 20-minute sweep cheap and non-destructive, and it preserves the engine invariant: the sentinel still means "everything before this timestamp has been scanned," it just advances once per close instead of once per run.
** Why a separate mode
The standard engine is one-shot and mutating — right for an at-the-desk "what's new?" glance, wrong for unattended polling: run every 20 minutes it would advance the sentinel past unprocessed items, spray reactive todos, take mail actions, and commit noise without review. Auto mode separates the cheap, frequent *watching* from the deliberate, gated *committing*, and adds the away-user's missing primitive — the running unacked-responses list.
* Reference
** Source Plugin Contract
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 classification shape. Since the 2026-07-18 digest format, Render blocks are *inputs to the Phase C synthesis*, not displayed sections — the digest is source-agnostic (TASKS / FYI / MISC). Keep the shape: it defines what the source considers reportable, and the long-form breakdown (on request) still uses it.
- =** 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
** 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 =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
# 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
If both fail, fall through to the resolution order above (prep doc → session file → session start).
** Output Template
The digest follows this shape (deltas only: a source with no changes contributes nothing; when *nothing* changed anywhere, the whole digest collapses to the one-line form below plus the timestamp — scan-failure banners always render regardless):
#+begin_example
17:39 sweep: no changes
#+end_example
When there are changes:
#+begin_example
<⚠ SCAN FAILED / backlog banners, if any>
==TASKS==
- <solo-executable items first, then the rest; priority order within each;
one prose bullet each: who, what, why-now, source link>
==FYI==
- <substantive work context, no action owed; priority order>
==MISC==
- <everything outside the project — personal mail, family, household;
priority order; action-ness stated inline>
1. Close the triage — file todo.org tasks for the TASKS items, run the standard close
2. Close the triage and execute the solo TASKS now, after filing the rest
Append "and reroute" to either option to send the outside-project items to their owning projects.
Or tell me what you'd like handled differently.
<run timestamp — always the final line>
#+end_example
Option 2 appears only when solo-executable TASKS exist; the reroute line only when MISC is non-empty. Sections with nothing in them are omitted. No per-source blocks, no itemized suggested-actions list — the close owns the routine hygiene. The long-form per-source breakdown is available on request only.
** 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. *Leaving the triage open, or re-asking about the routine hygiene.* The close is the default next action after the digest (Craig's 2026-07-18 ruling — no exceptions unless he says hold). Mail hygiene (trash/mark-read/star) runs at close without itemized confirmation. What still needs explicit confirmation: anything sending prose under Craig's name, and destructive actions beyond mail hygiene (branch deletes, PR merges).
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 sections.* TASKS = work items needing Craig only; work FYIs never appear there. Outside-project items always land in MISC, even when they carry an action. Solo items lead TASKS; priority order inside every section.
9. *Reporting a failed or skipped scan as a quiet source.* A hung receive, a dead daemon, or a skipped spin-up looks identical to "no new messages" in the output unless it's flagged. The 2026-06-10 sweep shipped with Signal silently missing because the scan hung on an account lock. Failures lead the summary, in their own banner line.
10. *Rendering a per-source quiet roll-call.* "Calendar — quiet" / "PRs — nothing new" lines on every silent source bury the one change that matters and pad a no-change sweep into a report. Deltas only: changed sources get blocks, unchanged sources get nothing, and an all-quiet sweep is one line (Craig's 2026-06-11 ruling in Phase C).
* History / Design Notes
** Living Document
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-07-20: Phone delivery for signal sweeps (=agent-text=, send half)
Auto mode now pushes a full-three-section sweep to Craig's phone over Signal via =agent-text=, the away-from-desk delivery the retired ntfy design carried before ntfy was torn down (2026-07-04). Transport is =agent-text= (the renamed Signal pager), not ntfy. Signal-only by Craig's 2026-07-20 ruling: a quiet sweep's =nothing= heartbeat never reaches the phone — silent-until-signal governs the phone channel too, so the higher-cost channel only fires when a sweep has real signal, while the in-session heartbeat stays as proof the loop ran. Falls back to inline when =agent-text= is absent. Only the send half ships; reply polling (the old =phone-recv=) waits on the reply-correlation follow-up, because a Signal reply fans out to every linked device and neither knows which page it answers.
**** 2026-07-18: Three-section digest + close-by-default (Phase C/D rewrite)
Craig's ruling after a 42h-gap sweep where the long-form report (top signals + per-source breakdown + 7-option action menu) was followed by "summarize the notable items" — and the digest that answered it was the report he wanted first. Phase C now renders ==TASKS== (work items needing Craig, solo-executable first, priority order) / ==FYI== (work context, no action owed) / ==MISC== (everything outside the project, actions stated inline), then exactly two options (close-and-file / close-and-execute-solo, the latter only when solo items exist), timestamp last. Per-source blocks and the itemized action menu are gone from the default surface (long form on request). Phase D became the close: it runs as the next action no matter what Craig replies (unless he explicitly holds), includes the mail hygiene on every scanned account without itemized confirmation, files the TASKS, clears resolved unacked items, advances the sentinel, and tears down started services. Solo = mechanical + standing-approved + no prose under Craig's name; prose sends and destructive non-mail actions stay gated. The stay-open-until-confirmed exit loop is retired. Same-day addendum: the "and reroute" modifier ("1 and reroute") — MISC items are surfaced-only by default (never filed to this project's todo.org); appending the modifier delivers each outside-project item to its owner's inbox via inbox-send per the cross-project rule.
**** 2026-06-15: Auto mode (unattended monitoring)
Added a self-running mode for when Craig is away but wants tight awareness — a =/loop= in the live session running accumulate-don't-mutate sweeps with "close the triage" / "stop the triage" as the gated checkpoint. Born the morning Craig cleared his day for a family emergency and wanted the desk watched while in and out. Design decisions (work-project proposal, ratified by Craig 2026-06-15): the unacked-responses list is durable in =.ai/triage-intake-unacked.org= (survives a crash/clear, the away-from-desk case it exists for); the sentinel advances only at close, preserving the scanned-before invariant; delivery is an in-session loop so MCP auth is inherited (a detached cron schedule belongs to the morning-ops orchestrator, not here, because of the headless-auth wall); it stays a mode of this engine, distinct from but reusable by that orchestrator. Same-day addendum (work, 2026-06-15): each sweep ends with a date/time/timezone stamp on its own final line (printed on quiet sweeps too, as proof the loop ran) so an away reader gauges freshness at a glance.
**** 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-06-11: Deltas-only reporting (Phase C + Output Template + Common Mistake 10)
Craig, via the work project's same-day handoff: "we only need to report if anything's changed when we do triage intake." Sweep summaries report deltas only — a new invite, a new/moved/cancelled event, a new message needing attention. Unchanged sources get no block (the "Calendar — quiet" roll-call is retired), and an all-quiet sweep renders as a single "HH:MM sweep: no changes" line. Failures keep their loud banner (never folded into the no-change line) and the suggested-actions line stays when actions are queued. Same ruling: the telegram plugin's dev-community group traffic is dropped from reports entirely unless Craig asks (see that plugin's 2026-06-11 note).
**** 2026-06-10: Loud failure surfacing (Phase C item 0 + Common Mistake 9)
Craig: "highlight any failures in daily triage loudly. I get important communication from all these channels." Trigger: the 2026-06-10 sweep shipped with Signal silently missing — a standalone receive hung on the signal-cli account lock while another client held it, and the failure looked identical to a quiet source. Failures now lead the summary in a ⚠ SCAN FAILED banner; the telegram plugin's failure path points at this rule.
**** 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.
|