← back to Claude Codex Meeting
run-meeting.sh
300 lines
#!/bin/bash
# Daily Claude-Codex Meeting runner.
# Three modes:
# morning — 9am daily; reviews overnight, sets agent assignments
# evening — 5pm daily; recaps the day
# sunday-digest — 6pm Sunday; weekly DW overnight digest
#
# Each meeting:
# 1. Loads the prior meeting's summary (carryover items) — sanitized
# 2. Gathers context (pm2 fleet status, recent crashes, open TODOs, git activity)
# 3. Hands the bundle to `claude --print` with the meeting-type prompt
# 4. Content-sniffs the output for hallucinated tool-failure footers; on hit,
# marks status=leaked-meta and DOES NOT update last-meeting symlink or email
# 5. Updates last-meeting.md symlink (relative + atomic via tmp+rename)
# 6. Emails the result to Steve via George (info@ → steve@designerwallcoverings.com)
set +x
set -uo pipefail
# NB: -e intentionally omitted — context-gathering uses pipelines (grep|head, find|read)
# that legitimately return non-zero when there's nothing to match.
TYPE="${1:?usage: run-meeting.sh <morning|evening|sunday-digest>}"
case "$TYPE" in morning|evening|sunday-digest) ;; *) echo "bad type: $TYPE"; exit 2;; esac
DIR="$HOME/Projects/claude-codex-meeting"
MEETINGS="$DIR/meetings"
LOGS="$DIR/logs"
PROMPTS="$DIR/prompts"
TS="$(date +%Y-%m-%d_%H%M%S)" # seconds included so two cadences in same minute don't clobber
DOW="$(date +%A)"
OUT="$MEETINGS/${TS}-${TYPE}.md"
CTX="$LOGS/${TS}-${TYPE}.context.md"
LAST="$DIR/last-meeting.md"
# Cadence/day-of-week assertion — sunday-digest must only ever fire on Sunday
# unless explicitly forced. Caught in the round-2 cross-exam.
if [[ "$TYPE" == "sunday-digest" && "$DOW" != "Sunday" && "${FORCE:-}" != "1" ]]; then
echo "[abort] sunday-digest fired on $DOW — refusing without FORCE=1" >&2
exit 4
fi
mkdir -p "$MEETINGS" "$LOGS"
log() { echo "[$(date +%H:%M:%S)] $*"; }
exec >> "$LOGS/run.log" 2>&1
log "===== meeting start type=$TYPE ts=$TS dow=$DOW ====="
# ---------- helpers ----------
# Single shared regex for leak-phrase detection — used by both detector and
# sanitizer so they can't drift (round-8 finding H3). Negative blacklist:
# patterns the model uses when its Write/Edit tool call got blocked headless.
LEAK_RE='(Here.{0,5}s the (digest|meeting).*paste|I.{0,2}ll paste|I will paste|Here is the (digest|meeting):|The write.*was blocked|^I (cannot|can.{0,2}t) (write|save|paste|generate)|write.{0,5}was blocked by permission|Approve it.*if you|attempted to (write|save) but)'
# Detect the leaked-meta failure mode. Two-part check (round-8 H2):
# 1. negative — must NOT contain a leak-phrase
# 2. positive — MUST contain at least one markdown heading line so a
# legitimate-but-unfamiliar failure mode is still flagged as malformed
# Returns 0 if leaked/malformed, 1 if clean.
is_leaked_meta() {
local f="$1"
# Negative: any leak phrase line-anchored
if grep -qiE "^$LEAK_RE" "$f" 2>/dev/null; then
return 0
fi
# Positive: must contain at least one markdown heading (### or ##) and
# be at least 5 lines. A "clean" output that's just prose without headings
# is also flagged — meeting templates always include section headings.
if ! grep -qE '^#{2,3} ' "$f" 2>/dev/null; then
return 0
fi
return 1
}
# Strip the leaked-meta wrapper from a contaminated file so the carryover
# content can still be partially used. Uses the SAME regex as the detector.
strip_leaked_meta() {
local f="$1"
awk -v re="$LEAK_RE" '
BEGIN { IGNORECASE = 1 }
NR<=3 && $0 ~ "^"re { next }
$0 ~ re && /(was blocked|approve it.*if you|attempted to)/ { skip=1 }
skip && /^$/ { skip=0; next }
skip { next }
{ print }
' "$f"
}
# ---------- 1. Gather context ----------
{
echo "## Meeting context — $TYPE — $(date '+%a %Y-%m-%d %H:%M %Z')"
echo
echo "### Last meeting carryover"
if [[ -f "$LAST" ]]; then
if is_leaked_meta "$LAST"; then
echo "_Prior meeting was contaminated (leaked-meta detected) — carryover quarantined; using sanitized fallback only._"
echo
strip_leaked_meta "$LAST" | head -200
else
head -200 "$LAST"
fi
else
echo "_No prior meeting on file._"
fi
echo
echo "### PM2 fleet status (top 30)"
pm2 jlist 2>/dev/null \
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{const j=JSON.parse(d);j.slice(0,40).forEach(p=>console.log("- "+p.name+" "+(p.pm2_env?.status||"?")+" restarts="+(p.pm2_env?.restart_time||0)+" uptime="+Math.round(((Date.now()-(p.pm2_env?.pm_uptime||0))/3600000))+"h"))})' \
|| echo "_pm2 not available_"
echo
echo "### Crashed/unstable agents (last 24h, restart_time>3 OR errored)"
pm2 jlist 2>/dev/null \
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{const j=JSON.parse(d);j.filter(p=>p.pm2_env?.status==="errored"||(p.pm2_env?.restart_time||0)>3).forEach(p=>console.log("- "+p.name+" status="+p.pm2_env?.status+" restarts="+(p.pm2_env?.restart_time||0)))})' \
|| true
echo
echo "### Recent git activity across ~/Projects"
for d in ~/Projects/*/; do
[[ -d "$d/.git" ]] || continue
name=$(basename "$d")
last=$(git -C "$d" log -1 --format='%ar — %s' 2>/dev/null | awk '{print substr($0,1,100)}')
[[ -n "$last" ]] && echo "- $name: $last"
done | head -30
echo
echo "### Open TODO.md across projects (top 30 lines total)"
find ~/Projects -maxdepth 3 -name TODO.md 2>/dev/null | head -10 | while IFS= read -r f; do
proj=$(basename "$(dirname "$f")")
echo "- $proj:"
grep -E '^\s*-\s*\[ \]' "$f" 2>/dev/null | head -5 | sed 's/^/ /'
done
echo
if [[ "$TYPE" == "sunday-digest" ]]; then
echo "### DW overnight (last 7 days) — Designer-Wallcoverings activity"
git -C ~/Projects/Designer-Wallcoverings log --since='7 days ago' --pretty='- %ar: %s' 2>/dev/null | head -20 || true
echo
fi
} > "$CTX"
log "context bundled $(wc -l < "$CTX") lines → $CTX"
# ---------- 2. Build prompt ----------
PROMPT_FILE="$PROMPTS/${TYPE}.md"
[[ -f "$PROMPT_FILE" ]] || { echo "missing prompt: $PROMPT_FILE"; exit 3; }
FULL_PROMPT="$(cat "$PROMPT_FILE")
---
$(cat "$CTX")"
# ---------- 3. Call claude headless ----------
log "calling claude --print model=sonnet …"
STATUS="ok"
if command -v claude >/dev/null 2>&1; then
# printf, not echo — preserves leading dashes/backslashes in prompt content.
# --disallowedTools belt-and-suspenders for the leaked-meta failure mode.
printf '%s\n' "$FULL_PROMPT" \
| claude --print --model sonnet --disallowedTools 'Write Edit Read Bash' \
> "$OUT" 2>>"$LOGS/claude.err" || {
log "claude --print returned non-zero exit; saving stub"
cat > "$OUT" <<EOF
# Meeting failed — claude CLI returned non-zero
See $LOGS/claude.err for details.
Context bundle:
$(cat "$CTX")
EOF
STATUS="claude-error"
}
else
log "claude CLI not on PATH — saving raw context as meeting"
cp "$CTX" "$OUT"
STATUS="no-claude-cli"
fi
LINES=$(wc -l < "$OUT" | tr -d ' ')
# Log this call to the cost ledger (Anthropic Max plan = $0 incremental, but useful for attribution)
if [[ -x "$HOME/.claude/skills/cost-tracker/scripts/log.js" && "$STATUS" == "ok" ]]; then
# Use char count as a rough proxy unit since we don't have token counts from claude --print
INPUT_CHARS=$(wc -c < "$CTX" | tr -d ' ')
OUTPUT_CHARS=$(wc -c < "$OUT" | tr -d ' ')
# Anthropic Max plan rates are $0 — this entry exists for call-count attribution only
node "$HOME/.claude/skills/cost-tracker/scripts/log.js" \
--api anthropic_max_plan \
--units "${INPUT_CHARS}:input_token,${OUTPUT_CHARS}:output_token" \
--app codex-meeting --note "$(basename "$OUT" .md) (~chars as token proxy)" 2>/dev/null || true
fi
# ---------- 3a. Content-quality gate (the gap the debate found) ----------
if [[ "$STATUS" == "ok" ]] && is_leaked_meta "$OUT"; then
STATUS="leaked-meta"
fi
if [[ "$STATUS" == "ok" && $LINES -lt 5 ]]; then
STATUS="too-short"
fi
log "meeting written lines=$LINES status=$STATUS → $OUT"
# ---------- 4. Update last-meeting symlink (only on clean status) ----------
if [[ "$STATUS" == "ok" ]]; then
# Truly atomic swap: write a tmp symlink, then rename(2) over the target.
# `ln -sfn` on macOS/BSD is unlink+symlink (NOT atomic — round-8 C1) so
# we use the tmp+mv pattern. Relative target survives clone/move.
cd "$DIR"
TMP_LINK="last-meeting.md.tmp.$$"
ln -s "meetings/$(basename "$OUT")" "$TMP_LINK"
mv -f "$TMP_LINK" "last-meeting.md"
log "symlink updated last-meeting.md → $(basename "$OUT")"
else
log "symlink NOT updated — status=$STATUS (this run quarantined; carryover stays clean)"
fi
# ---------- 5. Email via George (only on clean status) ----------
if [[ "$STATUS" != "ok" ]]; then
log "email skipped — status=$STATUS"
log "===== meeting end ====="
exit 0
fi
GEORGE="${GEORGE_URL:-http://localhost:9850}"
# No password fallback — env-only. Bring up secrets-manager .env or skip email.
if [[ -z "${GEORGE_AUTH:-}" && -f "$HOME/Projects/secrets-manager/.env" ]]; then
# Strip only outer matching quote pair (round-8 M1 — bare tr corrupts
# tokens that legitimately contain quotes inside).
GEORGE_AUTH_VAL=$(grep -E '^GEORGE_AUTH=' "$HOME/Projects/secrets-manager/.env" 2>/dev/null | head -1 | cut -d= -f2- | sed -E "s/^['\"](.*)['\"]\$/\1/")
[[ -n "$GEORGE_AUTH_VAL" ]] && GEORGE_AUTH="$GEORGE_AUTH_VAL"
fi
if [[ -z "${GEORGE_AUTH:-}" ]]; then
log "GEORGE_AUTH not set in env or secrets-manager — email skipped"
log "===== meeting end ====="
exit 0
fi
SUBJECT="[Codex-Claude Meeting] ${TYPE} — $(date '+%a %b %-d')"
# Health probe — drop -f so 401 status reaches the gate (the round-1 finding)
HEALTH_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$GEORGE/health" 2>/dev/null)
if [[ "$HEALTH_CODE" == "200" || "$HEALTH_CODE" == "401" ]]; then
# Capture both response body and HTTP status — round-8 H1 caught that
# we were logging "email queued" even on a POST 401/500.
# Body is markdown — convert to HTML so Gmail renders headings/bullets/line
# breaks instead of one collapsed paragraph (Steve flagged 2026-05-01).
# Codex P2 (same day): if `marked` is missing (fresh checkout, wiped
# node_modules), fall back to a <pre>-wrapped plaintext body so we still
# email something rather than POSTing an empty/invalid JSON payload.
POST_BODY=$(node -e '
const fs = require("fs");
const path = require("path");
const md = fs.readFileSync(process.argv[1], "utf8");
let html;
try {
const { marked } = require(path.join(process.env.HOME, "Projects/claude-codex-meeting/node_modules/marked"));
html = marked.parse(md, { breaks: true, gfm: true });
} catch (e) {
// Plaintext fallback: HTML-escape the markdown body and wrap in <pre>.
const esc = md.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
html = "<pre style=\"font-family:ui-monospace,Menlo,monospace;white-space:pre-wrap;\">" + esc + "</pre>";
process.stderr.write("[meeting] marked unavailable, falling back to <pre> plaintext: " + e.message + "\n");
}
const wrapped =
"<div style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;" +
"font-size:14px;line-height:1.55;color:#1a1a1a;max-width:720px;\">" +
html +
"</div>";
process.stdout.write(JSON.stringify({
to: "steve@designerwallcoverings.com",
subject: process.argv[2],
body: wrapped,
}));
' "$OUT" "$SUBJECT")
# Belt-and-suspenders: if node failed entirely (POST_BODY empty), skip the
# email rather than POST garbage.
if [[ -z "$POST_BODY" ]]; then
log "email payload generation failed — skipping email send"
log "===== meeting end ====="
exit 0
fi
RESP_AND_CODE=$(curl -s --max-time 30 -w '\n__HTTP_STATUS__%{http_code}' \
-H "Authorization: $GEORGE_AUTH" \
-H "Content-Type: application/json" \
-X POST "$GEORGE/api/send?account=info" \
-d "$POST_BODY")
POST_CODE=$(printf '%s' "$RESP_AND_CODE" | sed -n 's/.*__HTTP_STATUS__\([0-9]*\)$/\1/p' | tail -1)
RESP=$(printf '%s' "$RESP_AND_CODE" | sed 's/__HTTP_STATUS__[0-9]*$//')
# Round-8 H1 fallback — empty body produces invalid JSON in NDJSON.
RESP="${RESP:-null}"
# Round-8 M2 — wc -c on macOS pads whitespace.
CHARS=$(wc -c < "$OUT" | tr -d ' ')
printf '%s\n' "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"meeting\":\"$(basename "$OUT")\",\"type\":\"$TYPE\",\"chars\":$CHARS,\"http\":$POST_CODE,\"resp\":$RESP}" >> "$LOGS/email.log"
if [[ "$POST_CODE" == "200" ]]; then
log "email queued via george (health=$HEALTH_CODE post=$POST_CODE)"
else
log "email POST FAILED (health=$HEALTH_CODE post=$POST_CODE) — george reachable but rejected"
fi
else
log "george unreachable (health=$HEALTH_CODE); meeting saved locally only"
fi
log "===== meeting end ====="