Skip to content

Discord ops

Discord is Echo’s human-in-the-loop channel. When an item lands in Needs Human, Echo posts it to a project’s Discord channel; when an operator replies, Echo routes that reply back into the board. Both directions are deterministic scripts driven by the hub session’s Discord MCP tool — the scripts never touch the network themselves.

An escalation is queued as a file, not posted directly. flip_to_needs_human.sh (and finalize_watcher.sh on a failed deploy) writes a JSON payload (issue, title, reason, project_item_id, queued_at) into the project’s outbox_dir. Posting is a separate, later step so a transient Discord failure never blocks the status flip.

  1. Drain (drain_outbox.sh --project <root>). For each queued file it does an atomic mv into notifications_dir — that move is the per-file lock, so only one concurrent tick claims each file (this is the fix for the old double-post bug). It resolves the project’s discordChannelId from the registry and emits one JSON line per claimed file: the payload plus channel_id and delivered_path. Mention triggers (@everyone, @here, <@id>) in the untrusted title/reason are defanged with a zero-width space in the emitted copy before it’s posted.

  2. Post. The hub session (see /echo:tick) reads each emitted line and posts it to Discord via the MCP reply tool with chat_id = channel_id, as a compact one-liner:

    ⚠️ Echo · project · #issuetitle” needs a human — reason

  3. Record (record_delivery.sh --delivered-path <p> --message-id <id>). On a successful post the hub stamps the returned Discord message_id into the delivered record, so an inbound quote-reply can be matched back to the item. If the post fails, the file is moved back to outbox_dir and the next tick retries.

After draining, the hub calls the MCP fetch_messages tool (limit = discord.replyLookback, default 50), writes the messages to a temp file, and runs route_replies.sh --project <root> --messages <file>. For each reply it finds the matching Needs-Human record and re-queues the item.

Matching is, in order:

  1. By reference — the reply’s referenced_message_id equals a record’s stamped discord_message_id (from record_delivery.sh).
  2. By #N fallback — a leading #<digits> in the reply text, matched to the newest unresolved record for that issue.

A normal reply flips the item Needs Human → Ready via flip_to_ready.sh, attaching the reply text as operator guidance on the issue. Each record is marked resolved so a reply is acted on exactly once.

If the reply text, trimmed and lowercased, exactly equals one of discord.approveKeywords (default ["approve", "merge"]), and the held item has an open worker PR, route_replies.sh treats it as the human merge gate: it merges the open PR directly (gh pr merge --squash --delete-branch) instead of re-queuing a worker that would just re-hit the merge gate.

The match is exact, not a prefix — so "approve after you fix the test" or "merge conflict, please rebase" are read as guidance and re-queue the item, not as an approval. If there’s no open PR, the reply falls through to the normal re-queue.

Reply routing is gated by discord.replyAuthors, a list of Discord user IDs. The author check runs before any matching, so it authenticates both the reference match and the #N fallback.

// config: enable inbound replies for two operators
"discord": {
"replyLookback": 50,
"replyAuthors": ["427028945137827851", "1507389582902034462"],
"approveKeywords": ["approve", "merge"]
}

A reply from an author not on the list is logged and skipped without touching any record.