aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/workflows/triage-intake.org2
-rw-r--r--.ai/workflows/triage-intake.telegram.org198
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org2
-rw-r--r--claude-templates/.ai/workflows/triage-intake.telegram.org198
4 files changed, 398 insertions, 2 deletions
diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org
index e4d433d..265b883 100644
--- a/.ai/workflows/triage-intake.org
+++ b/.ai/workflows/triage-intake.org
@@ -48,7 +48,7 @@ The engine has no sources baked in. It discovers them by globbing *two* director
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, Signal, personal GitHub PRs).
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, 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.*
diff --git a/.ai/workflows/triage-intake.telegram.org b/.ai/workflows/triage-intake.telegram.org
new file mode 100644
index 0000000..68604bc
--- /dev/null
+++ b/.ai/workflows/triage-intake.telegram.org
@@ -0,0 +1,198 @@
+#+TITLE: Triage Intake — Telegram Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-09
+
+# 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.
+#
+# General (personal) source: Telegram via the Emacs telega.el package (tdlib
+# backend). It lives in .ai/workflows/ and is template-synced, sitting with the
+# other general personal sources (personal-gmail, cmail, personal-calendar,
+# signal, github-prs) — not the project plugins. Telegram is personal
+# messaging, not project-specific.
+#
+# Unlike signal-cli (a standalone CLI), Telegram has no headless CLI here. The
+# client is telega.el running inside Craig's long-lived `emacs --daemon`, so the
+# plugin drives it over `emacsclient -e`. tdlib keeps a persisted session in
+# ~/.telega (td.binlog), so a started telega reconnects without re-auth.
+
+* Source: telegram
+:PROPERTIES:
+:ORDER: 24
+:ENABLED: command -v emacsclient && emacsclient -e "(or (featurep 'telega) (fboundp 'telega))" | grep -q t
+:ANCHOR: none
+:SUBAGENT_OVER: 40
+:END:
+
+** Scan
+
+Telegram direct messages and groups via telega.el in the running Emacs daemon.
+=ANCHOR: none= because telega reports live unread *state* (each chat's
+=:unread_count=), not a since-window — the engine substitutes no cutoff. Phase B
+uses each message's timestamp only to order and label recency.
+
+The scan reads the =telega--chats= hash table (chat-id → chat plist), which
+telega populates as chats sync. *This is robust to the tdlib server crashing
+mid-session* (see the SEGFAULT gotcha below): the hash retains the last-synced
+unread counts and =:last_message= even after the server dies, so a scan reading
+the hash still returns the most recent known state.
+
+*** Leave-no-trace lifecycle (start only if needed, shut down only if we started it)
+
+telega is a long-lived client inside Craig's daemon. If he already has it
+running, the scan must leave it running. If it's *not* running, the scan starts
+it for the read and shuts it down cleanly afterward, restoring the daemon to its
+prior state. The discipline: *record the prior liveness, branch on it at the
+end.*
+
+*** Step 0 — record prior state
+
+#+begin_src bash
+# t if telega's tdlib server was ALREADY live before this scan, nil otherwise.
+# Hold this value; Step 3 reads it to decide whether to shut telega down.
+TELEGA_WAS_RUNNING=$(emacsclient -e "(and (fboundp 'telega-server-live-p) (telega-server-live-p) t)" 2>/dev/null)
+#+end_src
+
+*** Step 1 — start (docker mode) if not already running, wait for Ready
+
+#+begin_src bash
+# `(telega t)` starts without popping the root buffer. Docker mode (the stable
+# path — see the SEGFAULT gotcha) reconnects the persisted ~/.telega session in
+# ~2s. Then load the main chat list so telega--chats populates.
+emacsclient -e "(progn
+ (unless (and (fboundp 'telega-server-live-p) (telega-server-live-p)) (telega t))
+ 'started)"
+# Poll until Ready with chats synced, or a crash/timeout. Background this with an
+# until-loop so the wait doesn't block; exit on Ready-with-chats OR an abnormal
+# server exit. Then force a chat-list load if the hash is thin:
+emacsclient -e "(progn (ignore-errors (telega--loadChats 'main)) (ignore-errors (telega--loadChats 'main)) 'loaded)"
+#+end_src
+
+On a persisted session telega reaches status "Ready" within ~2s; the chat list
+loads over a few more. If =(hash-table-count telega--chats)= is 0 or thin,
+re-issue =telega--loadChats= and poll until it stabilizes.
+
+*** Step 2 — read unread, classified by last-message type
+
+The single most important filter: =messageContactRegistered=. Telegram counts a
+"<name> joined Telegram" service notice as one unread message, so every contact
+from Craig's old address book who ever joined shows as a 1-unread "DM" *that
+person never actually sent*. On the 2026-06-09 first scan this was 30 of ~50
+unread chats. Drop them entirely (tally only).
+
+#+begin_src bash
+emacsclient -e "(let (real svc other)
+ (when (boundp 'telega--chats)
+ (maphash (lambda (id chat)
+ (let* ((uc (or (plist-get chat :unread_count) 0))
+ (lm (plist-get chat :last_message))
+ (ctype (when lm (plist-get (plist-get lm :content) :@type)))
+ (title (or (ignore-errors (substring-no-properties (telega-chat-title chat))) \"?\")))
+ (when (> uc 0)
+ (cond
+ ((equal ctype \"messageContactRegistered\") (push title svc))
+ ((member ctype '(\"messageText\" \"messagePhoto\" \"messageVideo\" \"messageDocument\" \"messageVoiceNote\" \"messageSticker\" \"messageAnimation\"))
+ (push (list title uc ctype) real))
+ (t (push (list title uc (or ctype \"nil\")) other))))))
+ telega--chats))
+ (list (cons 'real (nreverse real))
+ (cons 'joined-telegram-count (length svc))
+ (cons 'other (nreverse other))))"
+#+end_src
+
+For a chat that survives as Action-worthy, pull the last message's text to
+classify and summarize:
+
+#+begin_src bash
+# <CHAT-ID> from the maphash key (the scan can also return ids alongside titles)
+emacsclient -e "(let ((c (gethash <CHAT-ID> telega--chats)))
+ (substring-no-properties
+ (or (telega--tl-get c :last_message :content :text :text) \"\")))"
+#+end_src
+
+*** Step 3 — restore prior state (shut down only if we started it)
+
+#+begin_src bash
+# If telega was NOT running before this scan, shut it down cleanly to leave the
+# daemon as we found it. If Craig already had it running, leave it alone.
+if [ "$TELEGA_WAS_RUNNING" != "t" ]; then
+ emacsclient -e "(progn (ignore-errors (telega-server-kill)) (ignore-errors (telega-kill t)) 'killed)"
+fi
+#+end_src
+
+⚠ *In docker mode, =telega-kill= alone is not enough.* =telega-kill= buries the
+telega buffers but leaves the dockerized tdlib server running (=telega-server-live-p=
+stays non-nil). =telega-server-kill= is what actually stops the server. Call
+*both* — server-kill then kill — for a clean teardown. Verified clean afterward:
+=telega-server-live-p= → nil, root buffer gone, no =zevlg/telega-server= container
+left in =docker ps=. Skipping this whole branch when =TELEGA_WAS_RUNNING= is t is
+the point of Step 0: never tear down a session Craig is actively using.
+
+⚠ *SEGFAULT GOTCHA — docker mode is the fix.* The tdlib =telega-server= can exit
+abnormally (code 139, SIGSEGV) during or just after the initial sync. Observed
+on the 2026-06-09 first run; the cure was *docker mode* — =telega-use-docker= = t
+with the =zevlg/telega-server:latest= image (already pulled on Craig's box).
+Restarted under docker, the server reached "Ready" in ~2s, synced 73 chats, and
+held through a live re-scan with no crash. So: *ensure docker mode is on before
+the sweep.* If =telega-use-docker= is nil, set it (=(setq telega-use-docker t)=)
+and restart telega.
+
+Defense in depth: even if the server does die, the scan still works because it
+reads the cached =telega--chats= hash, not a live query. A dead server is
+*scan-only* — you can still report unread state, but cannot read new bodies, mark
+read, or reply until it restarts. Treat that as "scan-only, no actions this run"
+and say so.
+
+** Classify
+
+Bias: Craig's personal Telegram is *spam-dominated* with a thin layer of real
+signal. The opposite of Signal (high signal/low volume) — here the volume is
+high and almost all noise. Filter aggressively; surface only the few real
+threads.
+
+- *Noise-trash (tally only, never itemized):*
+ - =messageContactRegistered= "joined Telegram" notices — always noise, no
+ matter whose name is on them. The real contacts Craig knows live here; a
+ join notice is not a message from them.
+ - Romance/crypto spam DMs — the signature is an emoji-laden handle or a
+ two-word "RealName + FantasyWord" suffix (=Gayle ⚾🤎RoyalVineyard=,
+ =Cherie🌷🏰 InfiniteRhapsody=, =Jane 🍒🔥=, =Luna Skye=). One unread,
+ unsolicited, no prior thread.
+ - =Deleted Account-NNNN= threads, blank-title chats, bot channels
+ (=Z-Library Official=), Telegram's own =✔️Telegram= service notices.
+- *FYI:* unread in dev-community groups Craig follows — =GNU Emacs=, =zed=,
+ =Kitty=, and similar. Worth awareness, no response owed. List the group +
+ unread count; don't enumerate the messages.
+- *Action:* a real text/voice/media message from a *known personal contact* in
+ an existing one-to-one thread — an explicit ask, a question, a reply owed. On
+ a spam-heavy account these are rare; when one appears, surface it prominently
+ with the sender + gist, because it's the needle in the haystack.
+
+The 2026-06-09 calibration run: 30 join-notices + ~10 spam/deleted/bot + 3 dev
+groups + 0 real personal DMs. Expect most sweeps to look like this — a clean
+"nothing real" is the common, correct result.
+
+** Render
+
+#+begin_example
+**Telegram — N unread chats (M real after filtering).** <one-line summary>
+- Action: <real DMs from known contacts, sender + gist, reply owed called out>
+- FYI: <dev groups — name + count>
+- Noise: K joined-Telegram notices, J spam/bot/deleted (tally only)
+#+end_example
+
+Omit the block if there are zero unread chats. If the only unread is
+join-notices + spam + groups (the common case), render the one-line summary plus
+the Noise tally and skip Action entirely.
+
+** Actions
+
+Actions need the tdlib server *live* (see the SEGFAULT gotcha — a dead server is
+scan-only). All run through telega in the daemon:
+
+- reply :: =emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"= — public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for multi-line.
+- mark-read :: =emacsclient -e "(telega-chat--mark-read (telega-chat-get <CHAT-ID>))"= — clears a specific chat's unread. Never mark the whole account read blindly; the join-notice noise is fine to leave or clear per Craig's call.
+- open :: =emacsclient -e "(telega-chat-with (telega-chat-get <CHAT-ID>))"= — pop the chat buffer for Craig to read/handle by hand (useful when a real DM needs a considered reply).
+
+No blanket mark-read verb: the account's unread is mostly join-notices and spam,
+and a real DM should be handled deliberately, not swept.
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index e4d433d..265b883 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -48,7 +48,7 @@ The engine has no sources baked in. It discovers them by globbing *two* director
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, Signal, personal GitHub PRs).
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, 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.*
diff --git a/claude-templates/.ai/workflows/triage-intake.telegram.org b/claude-templates/.ai/workflows/triage-intake.telegram.org
new file mode 100644
index 0000000..68604bc
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.telegram.org
@@ -0,0 +1,198 @@
+#+TITLE: Triage Intake — Telegram Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-09
+
+# 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.
+#
+# General (personal) source: Telegram via the Emacs telega.el package (tdlib
+# backend). It lives in .ai/workflows/ and is template-synced, sitting with the
+# other general personal sources (personal-gmail, cmail, personal-calendar,
+# signal, github-prs) — not the project plugins. Telegram is personal
+# messaging, not project-specific.
+#
+# Unlike signal-cli (a standalone CLI), Telegram has no headless CLI here. The
+# client is telega.el running inside Craig's long-lived `emacs --daemon`, so the
+# plugin drives it over `emacsclient -e`. tdlib keeps a persisted session in
+# ~/.telega (td.binlog), so a started telega reconnects without re-auth.
+
+* Source: telegram
+:PROPERTIES:
+:ORDER: 24
+:ENABLED: command -v emacsclient && emacsclient -e "(or (featurep 'telega) (fboundp 'telega))" | grep -q t
+:ANCHOR: none
+:SUBAGENT_OVER: 40
+:END:
+
+** Scan
+
+Telegram direct messages and groups via telega.el in the running Emacs daemon.
+=ANCHOR: none= because telega reports live unread *state* (each chat's
+=:unread_count=), not a since-window — the engine substitutes no cutoff. Phase B
+uses each message's timestamp only to order and label recency.
+
+The scan reads the =telega--chats= hash table (chat-id → chat plist), which
+telega populates as chats sync. *This is robust to the tdlib server crashing
+mid-session* (see the SEGFAULT gotcha below): the hash retains the last-synced
+unread counts and =:last_message= even after the server dies, so a scan reading
+the hash still returns the most recent known state.
+
+*** Leave-no-trace lifecycle (start only if needed, shut down only if we started it)
+
+telega is a long-lived client inside Craig's daemon. If he already has it
+running, the scan must leave it running. If it's *not* running, the scan starts
+it for the read and shuts it down cleanly afterward, restoring the daemon to its
+prior state. The discipline: *record the prior liveness, branch on it at the
+end.*
+
+*** Step 0 — record prior state
+
+#+begin_src bash
+# t if telega's tdlib server was ALREADY live before this scan, nil otherwise.
+# Hold this value; Step 3 reads it to decide whether to shut telega down.
+TELEGA_WAS_RUNNING=$(emacsclient -e "(and (fboundp 'telega-server-live-p) (telega-server-live-p) t)" 2>/dev/null)
+#+end_src
+
+*** Step 1 — start (docker mode) if not already running, wait for Ready
+
+#+begin_src bash
+# `(telega t)` starts without popping the root buffer. Docker mode (the stable
+# path — see the SEGFAULT gotcha) reconnects the persisted ~/.telega session in
+# ~2s. Then load the main chat list so telega--chats populates.
+emacsclient -e "(progn
+ (unless (and (fboundp 'telega-server-live-p) (telega-server-live-p)) (telega t))
+ 'started)"
+# Poll until Ready with chats synced, or a crash/timeout. Background this with an
+# until-loop so the wait doesn't block; exit on Ready-with-chats OR an abnormal
+# server exit. Then force a chat-list load if the hash is thin:
+emacsclient -e "(progn (ignore-errors (telega--loadChats 'main)) (ignore-errors (telega--loadChats 'main)) 'loaded)"
+#+end_src
+
+On a persisted session telega reaches status "Ready" within ~2s; the chat list
+loads over a few more. If =(hash-table-count telega--chats)= is 0 or thin,
+re-issue =telega--loadChats= and poll until it stabilizes.
+
+*** Step 2 — read unread, classified by last-message type
+
+The single most important filter: =messageContactRegistered=. Telegram counts a
+"<name> joined Telegram" service notice as one unread message, so every contact
+from Craig's old address book who ever joined shows as a 1-unread "DM" *that
+person never actually sent*. On the 2026-06-09 first scan this was 30 of ~50
+unread chats. Drop them entirely (tally only).
+
+#+begin_src bash
+emacsclient -e "(let (real svc other)
+ (when (boundp 'telega--chats)
+ (maphash (lambda (id chat)
+ (let* ((uc (or (plist-get chat :unread_count) 0))
+ (lm (plist-get chat :last_message))
+ (ctype (when lm (plist-get (plist-get lm :content) :@type)))
+ (title (or (ignore-errors (substring-no-properties (telega-chat-title chat))) \"?\")))
+ (when (> uc 0)
+ (cond
+ ((equal ctype \"messageContactRegistered\") (push title svc))
+ ((member ctype '(\"messageText\" \"messagePhoto\" \"messageVideo\" \"messageDocument\" \"messageVoiceNote\" \"messageSticker\" \"messageAnimation\"))
+ (push (list title uc ctype) real))
+ (t (push (list title uc (or ctype \"nil\")) other))))))
+ telega--chats))
+ (list (cons 'real (nreverse real))
+ (cons 'joined-telegram-count (length svc))
+ (cons 'other (nreverse other))))"
+#+end_src
+
+For a chat that survives as Action-worthy, pull the last message's text to
+classify and summarize:
+
+#+begin_src bash
+# <CHAT-ID> from the maphash key (the scan can also return ids alongside titles)
+emacsclient -e "(let ((c (gethash <CHAT-ID> telega--chats)))
+ (substring-no-properties
+ (or (telega--tl-get c :last_message :content :text :text) \"\")))"
+#+end_src
+
+*** Step 3 — restore prior state (shut down only if we started it)
+
+#+begin_src bash
+# If telega was NOT running before this scan, shut it down cleanly to leave the
+# daemon as we found it. If Craig already had it running, leave it alone.
+if [ "$TELEGA_WAS_RUNNING" != "t" ]; then
+ emacsclient -e "(progn (ignore-errors (telega-server-kill)) (ignore-errors (telega-kill t)) 'killed)"
+fi
+#+end_src
+
+⚠ *In docker mode, =telega-kill= alone is not enough.* =telega-kill= buries the
+telega buffers but leaves the dockerized tdlib server running (=telega-server-live-p=
+stays non-nil). =telega-server-kill= is what actually stops the server. Call
+*both* — server-kill then kill — for a clean teardown. Verified clean afterward:
+=telega-server-live-p= → nil, root buffer gone, no =zevlg/telega-server= container
+left in =docker ps=. Skipping this whole branch when =TELEGA_WAS_RUNNING= is t is
+the point of Step 0: never tear down a session Craig is actively using.
+
+⚠ *SEGFAULT GOTCHA — docker mode is the fix.* The tdlib =telega-server= can exit
+abnormally (code 139, SIGSEGV) during or just after the initial sync. Observed
+on the 2026-06-09 first run; the cure was *docker mode* — =telega-use-docker= = t
+with the =zevlg/telega-server:latest= image (already pulled on Craig's box).
+Restarted under docker, the server reached "Ready" in ~2s, synced 73 chats, and
+held through a live re-scan with no crash. So: *ensure docker mode is on before
+the sweep.* If =telega-use-docker= is nil, set it (=(setq telega-use-docker t)=)
+and restart telega.
+
+Defense in depth: even if the server does die, the scan still works because it
+reads the cached =telega--chats= hash, not a live query. A dead server is
+*scan-only* — you can still report unread state, but cannot read new bodies, mark
+read, or reply until it restarts. Treat that as "scan-only, no actions this run"
+and say so.
+
+** Classify
+
+Bias: Craig's personal Telegram is *spam-dominated* with a thin layer of real
+signal. The opposite of Signal (high signal/low volume) — here the volume is
+high and almost all noise. Filter aggressively; surface only the few real
+threads.
+
+- *Noise-trash (tally only, never itemized):*
+ - =messageContactRegistered= "joined Telegram" notices — always noise, no
+ matter whose name is on them. The real contacts Craig knows live here; a
+ join notice is not a message from them.
+ - Romance/crypto spam DMs — the signature is an emoji-laden handle or a
+ two-word "RealName + FantasyWord" suffix (=Gayle ⚾🤎RoyalVineyard=,
+ =Cherie🌷🏰 InfiniteRhapsody=, =Jane 🍒🔥=, =Luna Skye=). One unread,
+ unsolicited, no prior thread.
+ - =Deleted Account-NNNN= threads, blank-title chats, bot channels
+ (=Z-Library Official=), Telegram's own =✔️Telegram= service notices.
+- *FYI:* unread in dev-community groups Craig follows — =GNU Emacs=, =zed=,
+ =Kitty=, and similar. Worth awareness, no response owed. List the group +
+ unread count; don't enumerate the messages.
+- *Action:* a real text/voice/media message from a *known personal contact* in
+ an existing one-to-one thread — an explicit ask, a question, a reply owed. On
+ a spam-heavy account these are rare; when one appears, surface it prominently
+ with the sender + gist, because it's the needle in the haystack.
+
+The 2026-06-09 calibration run: 30 join-notices + ~10 spam/deleted/bot + 3 dev
+groups + 0 real personal DMs. Expect most sweeps to look like this — a clean
+"nothing real" is the common, correct result.
+
+** Render
+
+#+begin_example
+**Telegram — N unread chats (M real after filtering).** <one-line summary>
+- Action: <real DMs from known contacts, sender + gist, reply owed called out>
+- FYI: <dev groups — name + count>
+- Noise: K joined-Telegram notices, J spam/bot/deleted (tally only)
+#+end_example
+
+Omit the block if there are zero unread chats. If the only unread is
+join-notices + spam + groups (the common case), render the one-line summary plus
+the Noise tally and skip Action entirely.
+
+** Actions
+
+Actions need the tdlib server *live* (see the SEGFAULT gotcha — a dead server is
+scan-only). All run through telega in the daemon:
+
+- reply :: =emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"= — public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for multi-line.
+- mark-read :: =emacsclient -e "(telega-chat--mark-read (telega-chat-get <CHAT-ID>))"= — clears a specific chat's unread. Never mark the whole account read blindly; the join-notice noise is fine to leave or clear per Craig's call.
+- open :: =emacsclient -e "(telega-chat-with (telega-chat-get <CHAT-ID>))"= — pop the chat buffer for Craig to read/handle by hand (useful when a real DM needs a considered reply).
+
+No blanket mark-read verb: the account's unread is mostly join-notices and spam,
+and a real DM should be handled deliberately, not swept.