From 8def189a176486c2db5727b474a8ffd92d7c102a Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 7 Jul 2026 19:27:45 -0700 Subject: [PATCH] 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) --- .claude/commands/ask-forum.md | 45 ++++++++ .claude/scripts/ask-forum.sh | 192 ++++++++++++++++++++++++++++++++++ projects/discord-bot | 2 +- 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/ask-forum.md create mode 100644 .claude/scripts/ask-forum.sh diff --git a/.claude/commands/ask-forum.md b/.claude/commands/ask-forum.md new file mode 100644 index 00000000..a9ee8429 --- /dev/null +++ b/.claude/commands/ask-forum.md @@ -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 + +# Resume blocking on a thread already posted (e.g. after a timeout): +bash .claude/scripts/ask-forum.sh --wait --timeout 1800 --poll 10 +``` + +Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the +question), `--timeout SEC`, `--poll SEC`, `--tag @` (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 `). + +Channel: #ct-forum (`1522960388432465950`), private — @everyone denied; Howard, Mike, +and the bot allowed. Add someone via a channel permission overwrite if they need access. diff --git a/.claude/scripts/ask-forum.sh b/.claude/scripts/ask-forum.sh new file mode 100644 index 00000000..b4e2c9af --- /dev/null +++ b/.claude/scripts/ask-forum.sh @@ -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 [limit] +if [ "${1:-}" = "--read" ]; then + THREAD="${2:-}" + LIMIT="${3:-25}" + [ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read [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 [--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 [--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 diff --git a/projects/discord-bot b/projects/discord-bot index a35bd1bf..56938bbf 160000 --- a/projects/discord-bot +++ b/projects/discord-bot @@ -1 +1 @@ -Subproject commit a35bd1bf1758ac462abdf05bdd9ee6a1c7b427ee +Subproject commit 56938bbf5ad18a8afe9712891d5eabbc57c5fb10