ask-forum: human-in-the-loop question flow via #ct-forum
Add ask-forum.sh + /ask-forum command: a live Claude session posts a question to the private #ct-forum forum and blocks (server-side, one tool call) until a human replies, then acts on the answer. Same session throughout - a three-way between the user, the session, and the teammate. No DMs, no second bot. Bumps the discord-bot submodule to the matching #ct-forum guard. Approved by Mike via #ct-forum (a-go, forum-only, /ask-forum yes), 2026-07-08. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
.claude/commands/ask-forum.md
Normal file
45
.claude/commands/ask-forum.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: ask-forum
|
||||
description: Ask a teammate (e.g. Mike) a question in the private #ct-forum Discord forum and BLOCK until a human answers, then act on the reply — a live three-way between the user, this same Claude session, and the teammate. No second bot, no DMs, one tool call (the wait runs server-side in bash).
|
||||
---
|
||||
|
||||
# /ask-forum — human-in-the-loop question via #ct-forum
|
||||
|
||||
Thin entry point to `.claude/scripts/ask-forum.sh`. Lets THIS session put a question
|
||||
to a human in the private #ct-forum forum and get their answer back in-session, so it
|
||||
can keep going. The forum post's thread id is the correlation key — the session reads
|
||||
only the thread it created, so an answer is never confused with another question's.
|
||||
|
||||
Forum-only by design (Mike's call, 2026-07-08): DM replies are intentionally not used,
|
||||
so answers stay visible to the team. The BEAST bot ignores #ct-forum, so the asking
|
||||
session is the only agent that consumes replies there.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Ask and block until a human replies (default timeout 600s, poll 8s):
|
||||
bash .claude/scripts/ask-forum.sh "your question" --tag @264814939619721216
|
||||
|
||||
# Re-read a thread you already asked in (one-shot, no waiting):
|
||||
bash .claude/scripts/ask-forum.sh --read <thread_id>
|
||||
|
||||
# Resume blocking on a thread already posted (e.g. after a timeout):
|
||||
bash .claude/scripts/ask-forum.sh --wait <thread_id> --timeout 1800 --poll 10
|
||||
```
|
||||
|
||||
Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the
|
||||
question), `--timeout SEC`, `--poll SEC`, `--tag @<discord_id>` (repeatable — pings
|
||||
that person; ids in `.claude/users.json`: mike 264814939619721216,
|
||||
howard 624667664501178379, winter 624666486362996755, rob 261978810713505792).
|
||||
|
||||
## How to run it well
|
||||
|
||||
- **Long waits → background it** with `run_in_background: true` (NO shell `&` — that
|
||||
forks the wait off and exits early). You get the answer when the human replies.
|
||||
- Only **non-bot** replies count as answers, so bot chatter can never be mistaken for
|
||||
the human's answer.
|
||||
- Exit codes: 0 answered · 2 no token · 3 Discord API error · 4 timeout (thread stays
|
||||
open — resume with `--wait <thread_id>`).
|
||||
|
||||
Channel: #ct-forum (`1522960388432465950`), private — @everyone denied; Howard, Mike,
|
||||
and the bot allowed. Add someone via a channel permission overwrite if they need access.
|
||||
192
.claude/scripts/ask-forum.sh
Normal file
192
.claude/scripts/ask-forum.sh
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env bash
|
||||
# ask-forum.sh — ask a human a question in the #ct-forum Discord forum and BLOCK
|
||||
# until a person answers, then print their answer and exit. Built for a live
|
||||
# three-way: the user in the terminal, THIS Claude session, and a teammate (e.g.
|
||||
# Mike) in the forum — all the same session. The wait loop runs here in bash, so
|
||||
# the calling model pays for ONE tool call, not a polling loop.
|
||||
#
|
||||
# Usage:
|
||||
# ask-forum.sh "question text" [--title "short title"] [--timeout SEC] [--poll SEC] [--tag @id ...]
|
||||
# echo "question" | ask-forum.sh [flags]
|
||||
#
|
||||
# Flow:
|
||||
# 1. POST a new forum post (thread) to #ct-forum with the question.
|
||||
# 2. Poll that thread server-side for the first NON-BOT reply (the human answer).
|
||||
# The bot's own starter message and any bot chatter are ignored (author.bot).
|
||||
# 3. Print the answer (author + text) + the thread id, exit 0.
|
||||
# On timeout: exit 4 with the thread id so the caller can re-read or keep waiting.
|
||||
#
|
||||
# Correlation is automatic: we create the thread and read only that thread, so an
|
||||
# answer can never be confused with another question's.
|
||||
#
|
||||
# Token/behavior notes:
|
||||
# - Default timeout 600s, poll every 8s. The model is not involved during the wait.
|
||||
# - Only NON-BOT authors count as answers, so this is safe even if the BEAST bot
|
||||
# is still (mis)answering the channel — its replies are filtered out.
|
||||
#
|
||||
# Exit codes: 0 answered; 1 usage; 2 token missing; 3 Discord API error; 4 timeout.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
API="https://discord.com/api/v10"
|
||||
UA="ClaudeToolsBot (claudetools, 1.0)"
|
||||
FORUM_ID="1522960388432465950" # #ct-forum (private forum; Howard + bot + Mike)
|
||||
|
||||
TIMEOUT=600
|
||||
POLL=8
|
||||
TITLE=""
|
||||
TAGS=()
|
||||
MSG=""
|
||||
|
||||
# --- token resolver (shared by read + ask) ---
|
||||
resolve_token() {
|
||||
local t
|
||||
t="$(bash "$ROOT/.claude/scripts/vault.sh" get-field \
|
||||
projects/discord-bot/bot-token.sops.yaml credentials.bot_token 2>/dev/null)"
|
||||
if [ -z "$t" ] || [ "$t" = "null" ]; then
|
||||
local env_file="$ROOT/projects/discord-bot/.env"
|
||||
[ -f "$env_file" ] && t="$(grep -iE '^[[:space:]]*DISCORD_TOKEN[[:space:]]*=' "$env_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'"'"']//; s/["'"'"'][[:space:]]*$//')"
|
||||
fi
|
||||
printf '%s' "$t"
|
||||
}
|
||||
|
||||
# --- read mode: one-shot fetch of a thread's human replies, no posting ---
|
||||
# ask-forum.sh --read <thread_id> [limit]
|
||||
if [ "${1:-}" = "--read" ]; then
|
||||
THREAD="${2:-}"
|
||||
LIMIT="${3:-25}"
|
||||
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read <thread_id> [limit]" >&2; exit 1; }
|
||||
TOKEN="$(resolve_token)"
|
||||
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { echo "[ERROR] no Discord bot token" >&2; exit 2; }
|
||||
auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}")
|
||||
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=${LIMIT}")"
|
||||
printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || { echo "[ERROR] could not read thread ${THREAD}: $(printf '%s' "$MSGS" | head -c 160)" >&2; exit 3; }
|
||||
echo "[OK] thread ${THREAD} (oldest first; '>>>' = human, ' ' = bot):"
|
||||
printf '%s' "$MSGS" | jq -r 'reverse[] | ((if (.author.bot // false) then " " else ">>> " end) + .author.username + ": " + ((.content // "")|gsub("\n";" ")))'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- wait mode: block on an EXISTING thread until a human replies, no posting ---
|
||||
# ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC]
|
||||
# Baselines on the newest message present now and returns the first NON-BOT reply
|
||||
# that arrives after it. Use to resume waiting on a question already posted.
|
||||
if [ "${1:-}" = "--wait" ]; then
|
||||
THREAD="${2:-}"; shift 2
|
||||
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC]" >&2; exit 1; }
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
|
||||
--poll) POLL="${2:-8}"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
|
||||
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
|
||||
TOKEN="$(resolve_token)"
|
||||
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { echo "[ERROR] no Discord bot token" >&2; exit 2; }
|
||||
auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}")
|
||||
# baseline = id of newest message present right now
|
||||
AFTER="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=1" | jq -r '.[0].id // "0"' 2>/dev/null)"
|
||||
[ -z "$AFTER" ] && AFTER="0"
|
||||
echo "[INFO] waiting on thread ${THREAD} for a human reply (up to ${TIMEOUT}s)..." >&2
|
||||
DEADLINE=$(( $(date +%s) + TIMEOUT ))
|
||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||
sleep "$POLL"
|
||||
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}")"
|
||||
printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || continue
|
||||
HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content}')"
|
||||
if [ -n "$HUMAN" ]; then
|
||||
echo "[OK] answer received in thread ${THREAD}:"
|
||||
printf '%s\n' "$HUMAN" | jq -r '">>> " + .u + ": " + (.c|gsub("\n";" "))'
|
||||
echo "THREAD_ID=${THREAD}"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo "[TIMEOUT] no human reply on thread ${THREAD} within ${TIMEOUT}s" >&2
|
||||
echo "THREAD_ID=${THREAD}" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# --- parse args ---
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) TITLE="${2:-}"; shift 2 ;;
|
||||
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
|
||||
--poll) POLL="${2:-8}"; shift 2 ;;
|
||||
--tag) TAGS+=("${2:-}"); shift 2 ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 1 ;;
|
||||
*) MSG="${MSG:+$MSG }$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi
|
||||
if [ -z "$MSG" ]; then echo "[ERROR] no question given" >&2; exit 1; fi
|
||||
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
|
||||
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
|
||||
|
||||
# --- default title: first ~60 chars of the question ---
|
||||
if [ -z "$TITLE" ]; then
|
||||
TITLE="$(printf '%s' "$MSG" | tr '\n' ' ' | cut -c1-60)"
|
||||
[ -z "$TITLE" ] && TITLE="Question"
|
||||
fi
|
||||
|
||||
# --- prepend any @mentions so the tagged person gets pinged ---
|
||||
MENTION=""
|
||||
for t in "${TAGS[@]:-}"; do
|
||||
[ -z "$t" ] && continue
|
||||
id="${t#@}"
|
||||
printf '%s' "$id" | grep -qE '^[0-9]{17,19}$' && MENTION="${MENTION}<@${id}> "
|
||||
done
|
||||
CONTENT="${MENTION}${MSG}"
|
||||
|
||||
# --- bot token: vault first, then .env (same resolution as discord-dm.sh) ---
|
||||
TOKEN="$(resolve_token)"
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "[ERROR] no Discord bot token (vault + .env both empty)" >&2
|
||||
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "no Discord bot token" >/dev/null 2>&1
|
||||
exit 2
|
||||
fi
|
||||
auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}")
|
||||
|
||||
# --- create the forum post (thread) with the question ---
|
||||
BODY="$(jq -nc --arg name "$TITLE" --arg c "$CONTENT" '{name:$name, message:{content:$c}}')"
|
||||
RESP="$(printf '%s' "$BODY" | curl -s -m 20 -w $'\n%{http_code}' "${auth[@]}" \
|
||||
-X POST "$API/channels/${FORUM_ID}/threads" --data-binary @-)"
|
||||
HTTP="$(printf '%s' "$RESP" | tail -n1)"
|
||||
JSON="$(printf '%s' "$RESP" | sed '$d')"
|
||||
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
|
||||
echo "[ERROR] could not post forum question (HTTP ${HTTP:-none}): $(printf '%s' "$JSON" | head -c 200)" >&2
|
||||
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "forum post failed" --context "http=${HTTP:-none} resp=$(printf '%s' "$JSON" | head -c 80)" >/dev/null 2>&1
|
||||
exit 3
|
||||
fi
|
||||
THREAD="$(printf '%s' "$JSON" | jq -r '.id // empty')"
|
||||
STARTER="$(printf '%s' "$JSON" | jq -r '.message.id // empty')"
|
||||
if [ -z "$THREAD" ]; then
|
||||
echo "[ERROR] posted but no thread id in response: $(printf '%s' "$JSON" | head -c 200)" >&2
|
||||
exit 3
|
||||
fi
|
||||
echo "[INFO] asked in #ct-forum (thread=${THREAD}) — waiting up to ${TIMEOUT}s for a human reply..." >&2
|
||||
|
||||
# --- block-poll the thread for the first NON-BOT reply ---
|
||||
AFTER="${STARTER:-0}"
|
||||
DEADLINE=$(( $(date +%s) + TIMEOUT ))
|
||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||
sleep "$POLL"
|
||||
Q="after=${AFTER}"
|
||||
[ "$AFTER" = "0" ] && Q="limit=50"
|
||||
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?${Q}")"
|
||||
printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || continue
|
||||
# oldest-first; keep only human (non-bot) messages
|
||||
HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content, id:.id}')"
|
||||
if [ -n "$HUMAN" ]; then
|
||||
echo "[OK] answer received in thread ${THREAD}:"
|
||||
printf '%s\n' "$HUMAN" | jq -r '">>> " + .u + ": " + (.c|gsub("\n";" "))'
|
||||
echo "THREAD_ID=${THREAD}"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[TIMEOUT] no human reply within ${TIMEOUT}s. Thread stays open for a later read:" >&2
|
||||
echo "THREAD_ID=${THREAD}" >&2
|
||||
echo " re-read with: bash .claude/scripts/ask-forum.sh --read ${THREAD}" >&2
|
||||
exit 4
|
||||
Submodule projects/discord-bot updated: a35bd1bf17...56938bbf5a
Reference in New Issue
Block a user