aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/workflows/triage-intake.org12
-rw-r--r--.ai/workflows/triage-intake.signal.org109
-rw-r--r--.ai/workflows/triage-intake.telegram.org32
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org12
-rw-r--r--claude-templates/.ai/workflows/triage-intake.signal.org109
-rw-r--r--claude-templates/.ai/workflows/triage-intake.telegram.org32
6 files changed, 256 insertions, 50 deletions
diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org
index 265b883..173f051 100644
--- a/.ai/workflows/triage-intake.org
+++ b/.ai/workflows/triage-intake.org
@@ -95,6 +95,14 @@ Per-source bias (a work email account leans keep for audit value; a personal acc
One markdown summary surfaced inline to Craig. 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 Top signals:
+
+ #+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.
+
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.).
@@ -278,6 +286,7 @@ Order matters: top-signals first because that's what Craig reads in 30 seconds b
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.
+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.
* History / Design Notes
@@ -297,6 +306,9 @@ Gap-window bug: a run had Phase A fire at 13:35 and the sentinel set at 15:04, s
**** 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-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 account lock while the signel daemon owned it, and the failure looked identical to a quiet source. Failures now lead the summary in a ⚠ SCAN FAILED banner; the signal and telegram plugins' failure paths point 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.
diff --git a/.ai/workflows/triage-intake.signal.org b/.ai/workflows/triage-intake.signal.org
index 7c2690e..97f7fcd 100644
--- a/.ai/workflows/triage-intake.signal.org
+++ b/.ai/workflows/triage-intake.signal.org
@@ -1,30 +1,78 @@
#+TITLE: Triage Intake — Signal Source
#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-06-08
+#+DATE: 2026-06-10
# 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: Signal Private Messenger via signal-cli. It lives
-# in .ai/workflows/ and is template-synced, sitting with the other general
-# personal sources (personal-gmail, cmail, personal-calendar, github-prs) — not
-# the project plugins. Signal is personal messaging, not project-specific.
+# General (personal) source: Signal Private Messenger. TWO access paths exist
+# on Craig's machines, and picking the right one is the whole game:
#
-# Unlike cmail (which needs the cmail-action.py wrapper to drive Proton Bridge
-# IMAP), Signal needs no wrapper: signal-cli is already a full CLI with
-# first-class receive/send/sendReaction subcommands. The plugin calls it direct.
+# - signel (Emacs): Craig's Emacs daemon runs signal-cli in jsonRpc mode via
+# the signel package (~/code/signel, wired in ~/.emacs.d/modules/signal-config.el).
+# When that daemon is live, it OWNS the Signal account: it drains the
+# server queue continuously, writes messages into *Signel: <id>* buffers,
+# and fires desktop notifications.
+# - standalone signal-cli: works ONLY when signel's daemon is not running.
+#
+# ⚠ NEVER run a standalone `signal-cli receive` while signel's daemon is
+# live. signal-cli locks the account database, so the standalone call hangs
+# on the lock until killed, and the scan silently stalls (observed
+# 2026-06-10: a backgrounded receive blocked for 3+ minutes and the sweep
+# shipped without Signal). Detect first, then branch.
* Source: signal
:PROPERTIES:
:ORDER: 22
-:ENABLED: command -v signal-cli
+:ENABLED: command -v signal-cli || command -v emacsclient
:ANCHOR: none
:SUBAGENT_OVER: 40
:END:
** Scan
-Signal direct and group messages via signal-cli (Craig's linked device). =ANCHOR: none= because =signal-cli receive= drains the server-side queue — it returns exactly the envelopes that arrived since the last receive, so "new since last check" is intrinsic to the call. The engine substitutes no cutoff; Phase B uses each message's timestamp only to order and label recency.
+*** Step 0 — detect which path owns the account (mandatory, every run)
+
+#+begin_src bash
+SIGNEL_LIVE=$(emacsclient -e "(and (featurep 'signel) (process-live-p (get-process \"signal-rpc\")) t)" 2>/dev/null)
+# Belt and suspenders — the raw process check catches a signel daemon even
+# if the elisp probe fails:
+pgrep -f "org.asamk.signal.Main.*jsonRpc" >/dev/null && SIGNEL_LIVE=t
+echo "signel daemon live: ${SIGNEL_LIVE:-nil}"
+#+end_src
+
+- =t= → *Path A* (query through Emacs). Standalone receive is FORBIDDEN.
+- =nil= → *Path B* (standalone draining receive).
+- emacsclient unreachable AND signal-cli absent → SCAN FAILED. Surface loudly per the engine's failure rule; never report Signal as "quiet."
+
+*** Path A — query through Emacs (signel daemon live)
+
+signel keeps three sources of truth: =signel--active-chats= (hash of chat-ids seen this Emacs session), =signel--contact-map= (number → display name), and the =*Signel: <id>*= chat buffers (message lines stamped =[HH:MM]=, senders as =<Name>=, Craig's own as =<Me>=).
+
+One emacsclient call returns every active chat with its name and recent buffer tail:
+
+#+begin_src bash
+emacsclient -e "(progn (require 'signel)
+ (let (chats)
+ (maphash (lambda (id _v)
+ (push (list id (or (gethash id signel--contact-map) \"?\")
+ (let ((b (get-buffer (format \"*Signel: %s*\" id))))
+ (if b (with-current-buffer b
+ (buffer-substring-no-properties (max (point-min) (- (point-max) 1500)) (point-max)))
+ \"no buffer\")))
+ chats))
+ signel--active-chats)
+ chats))"
+#+end_src
+
+Phase B reads the =[HH:MM]= stamps against the anchor to find what's new, and reads who spoke last: a thread whose last line is =<Me>= (or a closing acknowledgment from the contact) carries no reply owed; a thread ending on the contact's question does.
+
+Path A caveats, stated honestly in the render when they bite:
+- =signel--active-chats= covers only chats with traffic since the Emacs daemon (or signel) last started. It is not a full history.
+- Buffer content is in-memory; an Emacs restart empties it. The phone and Signal Desktop still hold everything.
+- Timestamps are =[HH:MM]= with no date. Treat stamps as today's unless buffer position says otherwise.
+
+*** Path B — standalone draining receive (signel NOT running)
#+begin_src bash
acct=$(signal-cli listAccounts | awk 'NR==1{print $2}')
@@ -33,36 +81,49 @@ signal-cli -o json -a "$acct" receive --timeout 10 \
or .envelope.syncMessage.sentMessage.message != null)'
#+end_src
-=-o json= is a global flag, so it precedes the =receive= subcommand. The =jq= filter drops the bulk of what signal-cli emits — sync envelopes, delivery/read receipts, typing indicators — keeping only envelopes that carry real text: incoming =dataMessage= (someone messaged Craig) and =syncMessage.sentMessage= (Craig's own outgoing from another device, useful context for an in-flight thread). Per envelope: sender name =.envelope.sourceName=, sender number =.envelope.source=, body =.envelope.dataMessage.message=, timestamp =.envelope.timestamp= (epoch ms). Group messages carry =.envelope.dataMessage.groupInfo.groupId=.
-
-⚠ *DRAINING READ — read this twice.* =signal-cli receive= is destructive on the server queue: once an envelope is received and ACKed, the Signal server drops it, and a second =receive= will NOT return it. Capture everything from the one scan — there is no re-scan. The messages are not lost to Craig: his Signal Desktop and phone are independent linked devices with their own queues, so he still sees every message in the app. But the triage's own view is one-shot. Never fire a throwaway =receive= "just to check" and discard the output — those envelopes then vanish from the triage's reach (though not from Craig's app). This is the key difference from cmail and Gmail, where unread state persists until something explicitly clears it.
+⚠ *DRAINING READ.* =signal-cli receive= is destructive on this device's server queue: once an envelope is ACKed the server drops it and a second receive will NOT return it. Capture everything from the one scan. The messages are not lost to Craig (phone and Desktop are independent linked devices), but the triage's own view is one-shot. Never fire a throwaway receive.
-No contention with Signal Desktop: each linked device has its own server-side queue, so signal-cli draining its queue has no effect on what Desktop or the phone receive.
+⚠ Run Path B in the foreground, never backgrounded with stderr discarded — a lock conflict must fail loudly, not hang silently.
** Classify
-Bias: Signal is personal, conversational, and time-sensitive — it leans *Action* like Slack, because a direct message usually carries an implicit "respond." Almost all Signal traffic is from people Craig knows personally, so the volume is low and the signal-to-noise is high (the opposite of personal Gmail).
+Bias: Signal is personal, conversational, and time-sensitive — it leans *Action*, because a direct message usually carries an implicit "respond." Volume is low, signal-to-noise high (the opposite of personal Gmail).
-- *Action:* an explicit ask, a question, a scheduling request, a reply owed to a person.
-- *FYI:* a substantive message with no response owed — a link shared, a heads-up, an explicit "no need to reply."
-- *Noise-keep / trash:* automated Signal notifications, reactions, and Craig's own sent-message sync echoes (context only). Tally only.
+- *Action:* an explicit ask, a question, a scheduling request, a reply owed to a person. A thread ending on the contact's unanswered question is the prime case.
+- *FYI:* a substantive message with no response owed — a link shared, a heads-up, a thread Craig already closed (last word =<Me>= or a contact's closing ack).
+- *Noise-keep / trash:* automated notifications, reactions, sync echoes of Craig's own sends. Tally only.
Flag a message from a contact Craig is mid-thread with prominently — a dropped personal reply is the expensive miss here.
** Render
#+begin_example
-**Signal — N new messages.** <one-line classification summary>
+**Signal — N active chats, M with new traffic since anchor.** <one-line summary>
- Action: <items, sender + gist, reply owed called out>
-- FYI: <items, terse>
+- FYI: <threads with new traffic Craig already handled, terse>
- Noise: <tally>
+- (Path A caveat line, only when relevant: "covers chats since Emacs started <when>")
#+end_example
-Omit the block if zero new messages.
+Omit the block ONLY when the scan genuinely ran and found nothing. A scan that could not run is never "quiet" — it renders as a loud SCAN FAILED line at the top of the whole summary (engine rule).
** Actions
-- reply :: =signal-cli -a <acct> send -m "<body>" <recipient-number>= — recipient is =.envelope.source= (E.164, e.g. +37495833844). For a group, replace the number with =-g <groupId>= from =.envelope.dataMessage.groupInfo.groupId=. Public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for anything multi-line: =-m "$(cat /tmp/draft.txt)"=.
-- react :: =signal-cli -a <acct> sendReaction -e <emoji> -t <target-timestamp> -a <target-author> <recipient>= — optional acknowledgment without a full reply.
+*Path A (signel live)* — all through the daemon:
+
+- reply (programmatic) :: =emacsclient -e "(progn (require 'signel) (signel--send-rpc \"send\" '((message . \"<body>\") (recipient . [\"+1555...\"]))))"= — for a group, replace =recipient= with =(groupId . \"<id>\")=. Public-facing (goes out under Craig's name): run =/voice personal= first.
+- reply (interactive) :: =emacsclient -e "(cj/signel-message)"= pops Craig's contact picker + chat buffer for a considered reply by hand.
+- attach :: open the chat buffer, =signel-attach-file=.
+- start signel if Craig wants it up :: =emacsclient -e "(cj/signel-connect)"= (auto-starts the daemon and pre-warms contacts).
+
+*Path B (standalone)*:
+
+- reply :: =signal-cli -a <acct> send -m "<body>" <recipient-number>= — =/voice personal= first. Group: =-g <groupId>=.
+- react :: =signal-cli -a <acct> sendReaction -e <emoji> -t <target-timestamp> -a <target-author> <recipient>=.
+
+No mark-read step on either path: Path A's daemon already consumed the queue; Path B's draining receive did the same.
+
+** History
-No mark-read step: the draining receive already clears the queue, and signal-cli sends a delivery receipt automatically on receive. A read receipt is optional and rarely worth the extra call.
+- 2026-06-08: initial standalone-receive plugin.
+- 2026-06-10: rewrote around the signel discovery. Craig's Emacs daemon runs signal-cli jsonRpc via signel, which owns the account lock; a standalone receive hung a sweep silently. Added Step 0 detection, the Path A emacsclient query (verified live: caught two real threads the standalone path was blind to), the foreground-only rule for Path B, and the never-quiet-on-failure rule. Craig: Signal carries important communication; failures surface loudly.
diff --git a/.ai/workflows/triage-intake.telegram.org b/.ai/workflows/triage-intake.telegram.org
index 68604bc..d855fcf 100644
--- a/.ai/workflows/triage-intake.telegram.org
+++ b/.ai/workflows/triage-intake.telegram.org
@@ -24,6 +24,35 @@
:SUBAGENT_OVER: 40
:END:
+** Quick reference — full lifecycle
+
+Telega does not autostart with the Emacs daemon. "Down" is its normal state
+unless Craig has Telegram open in Emacs. The scan therefore runs the full
+lifecycle every time, never skips because the server is down:
+
+1. Record prior state: TELEGA_WAS_RUNNING via (telega-server-live-p).
+2. Launch (only if not running):
+ emacsclient -e "(progn (setq telega-use-docker t) (telega t) 'started)"
+ The setq is mandatory defense: tdlib segfaults outside docker mode
+ (2026-06-09), and Craig's daemon currently has telega-use-docker nil.
+ Wait ~2s for Ready, then (telega--loadChats 'main) until telega--chats
+ is populated.
+3. Check messages: the maphash unread scan in ** Scan Step 2 (filters the
+ messageContactRegistered join-notice noise).
+4. Send (needs the server live; /voice personal first — Telegram
+ occasionally carries WORK communication to Kostya and Vrezh, so treat
+ sends with the same care as Slack):
+ emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"
+5. Shutdown (ONLY if step 1 recorded not-running):
+ emacsclient -e "(progn (telega-server-kill) (ignore-errors (telega-kill t)) 'stopped)"
+ Verify: telega-server-live-p → nil, no zevlg/telega-server container in
+ docker ps. If Craig had it running, leave it untouched.
+
+If any lifecycle step fails (docker image missing, server crash, daemon
+unreachable), the sweep reports it as SCAN FAILED at the top of the summary
+per the engine's failure rule — never as a silent skip. Craig gets real
+traffic here.
+
** Scan
Telegram direct messages and groups via telega.el in the running Emacs daemon.
@@ -148,7 +177,8 @@ and say so.
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.
+threads. Kostya and Vrezh occasionally reach Craig here, so a real DM from a
+work contact is Action, full stop.
- *Noise-trash (tally only, never itemized):*
- =messageContactRegistered= "joined Telegram" notices — always noise, no
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index 265b883..173f051 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -95,6 +95,14 @@ Per-source bias (a work email account leans keep for audit value; a personal acc
One markdown summary surfaced inline to Craig. 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 Top signals:
+
+ #+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.
+
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.).
@@ -278,6 +286,7 @@ Order matters: top-signals first because that's what Craig reads in 30 seconds b
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.
+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.
* History / Design Notes
@@ -297,6 +306,9 @@ Gap-window bug: a run had Phase A fire at 13:35 and the sentinel set at 15:04, s
**** 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-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 account lock while the signel daemon owned it, and the failure looked identical to a quiet source. Failures now lead the summary in a ⚠ SCAN FAILED banner; the signal and telegram plugins' failure paths point 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.
diff --git a/claude-templates/.ai/workflows/triage-intake.signal.org b/claude-templates/.ai/workflows/triage-intake.signal.org
index 7c2690e..97f7fcd 100644
--- a/claude-templates/.ai/workflows/triage-intake.signal.org
+++ b/claude-templates/.ai/workflows/triage-intake.signal.org
@@ -1,30 +1,78 @@
#+TITLE: Triage Intake — Signal Source
#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-06-08
+#+DATE: 2026-06-10
# 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: Signal Private Messenger via signal-cli. It lives
-# in .ai/workflows/ and is template-synced, sitting with the other general
-# personal sources (personal-gmail, cmail, personal-calendar, github-prs) — not
-# the project plugins. Signal is personal messaging, not project-specific.
+# General (personal) source: Signal Private Messenger. TWO access paths exist
+# on Craig's machines, and picking the right one is the whole game:
#
-# Unlike cmail (which needs the cmail-action.py wrapper to drive Proton Bridge
-# IMAP), Signal needs no wrapper: signal-cli is already a full CLI with
-# first-class receive/send/sendReaction subcommands. The plugin calls it direct.
+# - signel (Emacs): Craig's Emacs daemon runs signal-cli in jsonRpc mode via
+# the signel package (~/code/signel, wired in ~/.emacs.d/modules/signal-config.el).
+# When that daemon is live, it OWNS the Signal account: it drains the
+# server queue continuously, writes messages into *Signel: <id>* buffers,
+# and fires desktop notifications.
+# - standalone signal-cli: works ONLY when signel's daemon is not running.
+#
+# ⚠ NEVER run a standalone `signal-cli receive` while signel's daemon is
+# live. signal-cli locks the account database, so the standalone call hangs
+# on the lock until killed, and the scan silently stalls (observed
+# 2026-06-10: a backgrounded receive blocked for 3+ minutes and the sweep
+# shipped without Signal). Detect first, then branch.
* Source: signal
:PROPERTIES:
:ORDER: 22
-:ENABLED: command -v signal-cli
+:ENABLED: command -v signal-cli || command -v emacsclient
:ANCHOR: none
:SUBAGENT_OVER: 40
:END:
** Scan
-Signal direct and group messages via signal-cli (Craig's linked device). =ANCHOR: none= because =signal-cli receive= drains the server-side queue — it returns exactly the envelopes that arrived since the last receive, so "new since last check" is intrinsic to the call. The engine substitutes no cutoff; Phase B uses each message's timestamp only to order and label recency.
+*** Step 0 — detect which path owns the account (mandatory, every run)
+
+#+begin_src bash
+SIGNEL_LIVE=$(emacsclient -e "(and (featurep 'signel) (process-live-p (get-process \"signal-rpc\")) t)" 2>/dev/null)
+# Belt and suspenders — the raw process check catches a signel daemon even
+# if the elisp probe fails:
+pgrep -f "org.asamk.signal.Main.*jsonRpc" >/dev/null && SIGNEL_LIVE=t
+echo "signel daemon live: ${SIGNEL_LIVE:-nil}"
+#+end_src
+
+- =t= → *Path A* (query through Emacs). Standalone receive is FORBIDDEN.
+- =nil= → *Path B* (standalone draining receive).
+- emacsclient unreachable AND signal-cli absent → SCAN FAILED. Surface loudly per the engine's failure rule; never report Signal as "quiet."
+
+*** Path A — query through Emacs (signel daemon live)
+
+signel keeps three sources of truth: =signel--active-chats= (hash of chat-ids seen this Emacs session), =signel--contact-map= (number → display name), and the =*Signel: <id>*= chat buffers (message lines stamped =[HH:MM]=, senders as =<Name>=, Craig's own as =<Me>=).
+
+One emacsclient call returns every active chat with its name and recent buffer tail:
+
+#+begin_src bash
+emacsclient -e "(progn (require 'signel)
+ (let (chats)
+ (maphash (lambda (id _v)
+ (push (list id (or (gethash id signel--contact-map) \"?\")
+ (let ((b (get-buffer (format \"*Signel: %s*\" id))))
+ (if b (with-current-buffer b
+ (buffer-substring-no-properties (max (point-min) (- (point-max) 1500)) (point-max)))
+ \"no buffer\")))
+ chats))
+ signel--active-chats)
+ chats))"
+#+end_src
+
+Phase B reads the =[HH:MM]= stamps against the anchor to find what's new, and reads who spoke last: a thread whose last line is =<Me>= (or a closing acknowledgment from the contact) carries no reply owed; a thread ending on the contact's question does.
+
+Path A caveats, stated honestly in the render when they bite:
+- =signel--active-chats= covers only chats with traffic since the Emacs daemon (or signel) last started. It is not a full history.
+- Buffer content is in-memory; an Emacs restart empties it. The phone and Signal Desktop still hold everything.
+- Timestamps are =[HH:MM]= with no date. Treat stamps as today's unless buffer position says otherwise.
+
+*** Path B — standalone draining receive (signel NOT running)
#+begin_src bash
acct=$(signal-cli listAccounts | awk 'NR==1{print $2}')
@@ -33,36 +81,49 @@ signal-cli -o json -a "$acct" receive --timeout 10 \
or .envelope.syncMessage.sentMessage.message != null)'
#+end_src
-=-o json= is a global flag, so it precedes the =receive= subcommand. The =jq= filter drops the bulk of what signal-cli emits — sync envelopes, delivery/read receipts, typing indicators — keeping only envelopes that carry real text: incoming =dataMessage= (someone messaged Craig) and =syncMessage.sentMessage= (Craig's own outgoing from another device, useful context for an in-flight thread). Per envelope: sender name =.envelope.sourceName=, sender number =.envelope.source=, body =.envelope.dataMessage.message=, timestamp =.envelope.timestamp= (epoch ms). Group messages carry =.envelope.dataMessage.groupInfo.groupId=.
-
-⚠ *DRAINING READ — read this twice.* =signal-cli receive= is destructive on the server queue: once an envelope is received and ACKed, the Signal server drops it, and a second =receive= will NOT return it. Capture everything from the one scan — there is no re-scan. The messages are not lost to Craig: his Signal Desktop and phone are independent linked devices with their own queues, so he still sees every message in the app. But the triage's own view is one-shot. Never fire a throwaway =receive= "just to check" and discard the output — those envelopes then vanish from the triage's reach (though not from Craig's app). This is the key difference from cmail and Gmail, where unread state persists until something explicitly clears it.
+⚠ *DRAINING READ.* =signal-cli receive= is destructive on this device's server queue: once an envelope is ACKed the server drops it and a second receive will NOT return it. Capture everything from the one scan. The messages are not lost to Craig (phone and Desktop are independent linked devices), but the triage's own view is one-shot. Never fire a throwaway receive.
-No contention with Signal Desktop: each linked device has its own server-side queue, so signal-cli draining its queue has no effect on what Desktop or the phone receive.
+⚠ Run Path B in the foreground, never backgrounded with stderr discarded — a lock conflict must fail loudly, not hang silently.
** Classify
-Bias: Signal is personal, conversational, and time-sensitive — it leans *Action* like Slack, because a direct message usually carries an implicit "respond." Almost all Signal traffic is from people Craig knows personally, so the volume is low and the signal-to-noise is high (the opposite of personal Gmail).
+Bias: Signal is personal, conversational, and time-sensitive — it leans *Action*, because a direct message usually carries an implicit "respond." Volume is low, signal-to-noise high (the opposite of personal Gmail).
-- *Action:* an explicit ask, a question, a scheduling request, a reply owed to a person.
-- *FYI:* a substantive message with no response owed — a link shared, a heads-up, an explicit "no need to reply."
-- *Noise-keep / trash:* automated Signal notifications, reactions, and Craig's own sent-message sync echoes (context only). Tally only.
+- *Action:* an explicit ask, a question, a scheduling request, a reply owed to a person. A thread ending on the contact's unanswered question is the prime case.
+- *FYI:* a substantive message with no response owed — a link shared, a heads-up, a thread Craig already closed (last word =<Me>= or a contact's closing ack).
+- *Noise-keep / trash:* automated notifications, reactions, sync echoes of Craig's own sends. Tally only.
Flag a message from a contact Craig is mid-thread with prominently — a dropped personal reply is the expensive miss here.
** Render
#+begin_example
-**Signal — N new messages.** <one-line classification summary>
+**Signal — N active chats, M with new traffic since anchor.** <one-line summary>
- Action: <items, sender + gist, reply owed called out>
-- FYI: <items, terse>
+- FYI: <threads with new traffic Craig already handled, terse>
- Noise: <tally>
+- (Path A caveat line, only when relevant: "covers chats since Emacs started <when>")
#+end_example
-Omit the block if zero new messages.
+Omit the block ONLY when the scan genuinely ran and found nothing. A scan that could not run is never "quiet" — it renders as a loud SCAN FAILED line at the top of the whole summary (engine rule).
** Actions
-- reply :: =signal-cli -a <acct> send -m "<body>" <recipient-number>= — recipient is =.envelope.source= (E.164, e.g. +37495833844). For a group, replace the number with =-g <groupId>= from =.envelope.dataMessage.groupInfo.groupId=. Public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for anything multi-line: =-m "$(cat /tmp/draft.txt)"=.
-- react :: =signal-cli -a <acct> sendReaction -e <emoji> -t <target-timestamp> -a <target-author> <recipient>= — optional acknowledgment without a full reply.
+*Path A (signel live)* — all through the daemon:
+
+- reply (programmatic) :: =emacsclient -e "(progn (require 'signel) (signel--send-rpc \"send\" '((message . \"<body>\") (recipient . [\"+1555...\"]))))"= — for a group, replace =recipient= with =(groupId . \"<id>\")=. Public-facing (goes out under Craig's name): run =/voice personal= first.
+- reply (interactive) :: =emacsclient -e "(cj/signel-message)"= pops Craig's contact picker + chat buffer for a considered reply by hand.
+- attach :: open the chat buffer, =signel-attach-file=.
+- start signel if Craig wants it up :: =emacsclient -e "(cj/signel-connect)"= (auto-starts the daemon and pre-warms contacts).
+
+*Path B (standalone)*:
+
+- reply :: =signal-cli -a <acct> send -m "<body>" <recipient-number>= — =/voice personal= first. Group: =-g <groupId>=.
+- react :: =signal-cli -a <acct> sendReaction -e <emoji> -t <target-timestamp> -a <target-author> <recipient>=.
+
+No mark-read step on either path: Path A's daemon already consumed the queue; Path B's draining receive did the same.
+
+** History
-No mark-read step: the draining receive already clears the queue, and signal-cli sends a delivery receipt automatically on receive. A read receipt is optional and rarely worth the extra call.
+- 2026-06-08: initial standalone-receive plugin.
+- 2026-06-10: rewrote around the signel discovery. Craig's Emacs daemon runs signal-cli jsonRpc via signel, which owns the account lock; a standalone receive hung a sweep silently. Added Step 0 detection, the Path A emacsclient query (verified live: caught two real threads the standalone path was blind to), the foreground-only rule for Path B, and the never-quiet-on-failure rule. Craig: Signal carries important communication; failures surface loudly.
diff --git a/claude-templates/.ai/workflows/triage-intake.telegram.org b/claude-templates/.ai/workflows/triage-intake.telegram.org
index 68604bc..d855fcf 100644
--- a/claude-templates/.ai/workflows/triage-intake.telegram.org
+++ b/claude-templates/.ai/workflows/triage-intake.telegram.org
@@ -24,6 +24,35 @@
:SUBAGENT_OVER: 40
:END:
+** Quick reference — full lifecycle
+
+Telega does not autostart with the Emacs daemon. "Down" is its normal state
+unless Craig has Telegram open in Emacs. The scan therefore runs the full
+lifecycle every time, never skips because the server is down:
+
+1. Record prior state: TELEGA_WAS_RUNNING via (telega-server-live-p).
+2. Launch (only if not running):
+ emacsclient -e "(progn (setq telega-use-docker t) (telega t) 'started)"
+ The setq is mandatory defense: tdlib segfaults outside docker mode
+ (2026-06-09), and Craig's daemon currently has telega-use-docker nil.
+ Wait ~2s for Ready, then (telega--loadChats 'main) until telega--chats
+ is populated.
+3. Check messages: the maphash unread scan in ** Scan Step 2 (filters the
+ messageContactRegistered join-notice noise).
+4. Send (needs the server live; /voice personal first — Telegram
+ occasionally carries WORK communication to Kostya and Vrezh, so treat
+ sends with the same care as Slack):
+ emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"
+5. Shutdown (ONLY if step 1 recorded not-running):
+ emacsclient -e "(progn (telega-server-kill) (ignore-errors (telega-kill t)) 'stopped)"
+ Verify: telega-server-live-p → nil, no zevlg/telega-server container in
+ docker ps. If Craig had it running, leave it untouched.
+
+If any lifecycle step fails (docker image missing, server crash, daemon
+unreachable), the sweep reports it as SCAN FAILED at the top of the summary
+per the engine's failure rule — never as a silent skip. Craig gets real
+traffic here.
+
** Scan
Telegram direct messages and groups via telega.el in the running Emacs daemon.
@@ -148,7 +177,8 @@ and say so.
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.
+threads. Kostya and Vrezh occasionally reach Craig here, so a real DM from a
+work contact is Action, full stop.
- *Noise-trash (tally only, never itemized):*
- =messageContactRegistered= "joined Telegram" notices — always noise, no