aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-08 18:22:26 -0500
committerCraig Jennings <c@cjennings.net>2026-06-08 18:22:26 -0500
commit627f521a2e1cc6830dc26a97251772084b708db4 (patch)
treebfeeccbe653413ce5ce3ca56095f5004157faa7a
parentd733bb29763d6e936b460dcf136a491c156eb888 (diff)
downloadrulesets-627f521a2e1cc6830dc26a97251772084b708db4.tar.gz
rulesets-627f521a2e1cc6830dc26a97251772084b708db4.zip
feat(triage-intake): add Signal source plugin
I added a Signal source plugin so the triage-intake sweep covers Signal alongside cmail, Gmail, calendar, and PRs. Signal is personal messaging, so it's a general plugin that syncs to every project. It needs no wrapper script, unlike cmail. signal-cli is already a full CLI, so the plugin drives receive and send directly. The scan filters signal-cli's JSON down to real messages and drops the sync, receipt, and typing noise. One sharp edge is documented in the plugin: signal-cli receive drains the server queue, so the triage gets one shot per message. Signal Desktop and the phone keep their own copies, so nothing's lost. I also added Signal to the engine's general-plugin list.
-rw-r--r--.ai/workflows/triage-intake.org2
-rw-r--r--.ai/workflows/triage-intake.signal.org68
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org2
-rw-r--r--claude-templates/.ai/workflows/triage-intake.signal.org68
4 files changed, 138 insertions, 2 deletions
diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org
index 844b2c0..e4d433d 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, personal GitHub PRs).
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, 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.signal.org b/.ai/workflows/triage-intake.signal.org
new file mode 100644
index 0000000..7c2690e
--- /dev/null
+++ b/.ai/workflows/triage-intake.signal.org
@@ -0,0 +1,68 @@
+#+TITLE: Triage Intake — Signal Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-08
+
+# 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.
+#
+# 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.
+
+* Source: signal
+:PROPERTIES:
+:ORDER: 22
+:ENABLED: command -v signal-cli
+: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.
+
+#+begin_src bash
+acct=$(signal-cli listAccounts | awk 'NR==1{print $2}')
+signal-cli -o json -a "$acct" receive --timeout 10 \
+ | jq -c 'select(.envelope.dataMessage.message != null
+ 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.
+
+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.
+
+** 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).
+
+- *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.
+
+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>
+- Action: <items, sender + gist, reply owed called out>
+- FYI: <items, terse>
+- Noise: <tally>
+#+end_example
+
+Omit the block if zero new messages.
+
+** 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.
+
+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.
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index 844b2c0..e4d433d 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, personal GitHub PRs).
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, 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.signal.org b/claude-templates/.ai/workflows/triage-intake.signal.org
new file mode 100644
index 0000000..7c2690e
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.signal.org
@@ -0,0 +1,68 @@
+#+TITLE: Triage Intake — Signal Source
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-08
+
+# 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.
+#
+# 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.
+
+* Source: signal
+:PROPERTIES:
+:ORDER: 22
+:ENABLED: command -v signal-cli
+: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.
+
+#+begin_src bash
+acct=$(signal-cli listAccounts | awk 'NR==1{print $2}')
+signal-cli -o json -a "$acct" receive --timeout 10 \
+ | jq -c 'select(.envelope.dataMessage.message != null
+ 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.
+
+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.
+
+** 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).
+
+- *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.
+
+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>
+- Action: <items, sender + gist, reply owed called out>
+- FYI: <items, terse>
+- Noise: <tally>
+#+end_example
+
+Omit the block if zero new messages.
+
+** 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.
+
+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.