← 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
      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 ====="