Skill: linkbag/ai-swarm (version 1.0.0)

--- name: ai-swarm description: Multi-agent AI coding swarm orchestration. Plan parallel tasks, spawn Claude/Codex/Gemini agents in tmux sessions with git worktrees, auto-review, auto-integrate branches, notify via Telegram. Use when you need to break a project into parallel coding tasks and orchestrate multiple AI agents to build, review, and merge them. --- # AI Swarm Orchestration Orchestrate parallel AI coding agents with automated review, integration, and notifications. ## Setup ### Prerequisites - `tmux` — agent sessions run here - `git` — worktrees for parallel branches - `claude` CLI (Claude Code) — primary agent - Telegram bot token + chat ID (optional, for notifications) ### Install scripts Copy `scripts/` to your swarm directory (e.g., `~/workspace/swarm/`). Make executable: ```bash chmod +x ~/workspace/swarm/*.sh ``` ### Initialize state files ```bash mkdir -p ~/workspace/swarm/{logs,endorsements} echo '[]' > ~/workspace/swarm/active-tasks.json echo '' > ~/workspace/swarm/pending-notifications.txt ``` ### Duty table Copy `references/duty-table-template.json` to `~/workspace/swarm/duty-table.json`. Configure model assignments. ## Core Workflow ### Phase 1: PLAN (present to human, STOP, wait for approval) ⛔ **CRITICAL GATE**: Present the plan table to the human. Then **STOP**. Do NOT write prompts or spawn agents until the human explicitly approves. The plan message and the spawn action **must be in DIFFERENT turns**. Never combine them. ``` 🐝 Swarm Plan: [batch description] | # | Task ID | Description | Agent | Model | |---|---------|-------------|-------|-------| | 1 | fix-xyz | Fix the freeze bug | claude | sonnet | | 2 | add-feat | Add feature X | claude | sonnet | Dependencies: None (all parallel) Estimated time: ~15-20 min Proceed? 👍/👎 ``` **Wait for human reply.** Only after "yes"/"go"/👍 → proceed to Phase 2. ### Phase 2: BUILD (write prompts, spawn agents) Write task prompts to `/tmp/prompt-<task-id>.md`, create batch JSON, then spawn: ```bash cat > /tmp/batch-tasks.json << 'EOF' [ {"id": "task-1", "description": "/tmp/prompt-task1.md", "agent": "claude", "model": "claude-sonnet-4-6"}, {"id": "task-2", "description": "/tmp/prompt-task2.md", "agent": "claude", "model": "claude-sonnet-4-6"} ] EOF cd ~/workspace/swarm bash spawn-batch.sh "/path/to/project" "batch-id" "Batch description" /tmp/batch-tasks.json ``` `spawn-batch.sh` handles everything automatically: - Creates git worktrees + feature branches - Launches agents in tmux sessions - Starts per-agent completion watchers (auto-review + Telegram notification) - Starts integration watcher (auto-merge when all done) For single tasks: `bash spawn-agent.sh "/path/to/project" "task-id" "/tmp/prompt.md" "claude" "claude-sonnet-4-6"` ### Phase 3: SHIP (automatic) The scripts handle this automatically: 1. `notify-on-complete.sh` detects each agent finishing → spawns reviewer → sends Telegram 2. `integration-watcher.sh` detects ALL agents done → spawns Opus integration agent → merges branches → resolves conflicts → verifies builds → sends Telegram ## Prompt Template Write clear, self-contained prompts for each agent. Include: - Project path and stack - Specific files to modify - What to do (detailed steps) - Verification commands (`tsc --noEmit`, `npm run build`, etc.) **Do NOT** include `openclaw system event` in prompts — `notify-on-complete.sh` handles notifications automatically. ## Monitoring ```bash tmux ls # List active agent sessions bash check-agents.sh # Health check all agents bash pulse-check.sh # Detect stuck agents (auto-kills) cat pending-notifications.txt # Check pending notifications ``` ## Script Reference | Script | Purpose | |--------|---------| | `spawn-batch.sh` | Spawn N agents + auto-integration watcher | | `spawn-agent.sh` | Spawn single agent with completion watcher | | `integration-watcher.sh` | Poll agents, auto-merge when all done | | `start-integration.sh` | Manual integration watcher start | | `notify-on-complete.sh` | Per-agent watcher: detect done → review → notify | | `pulse-check.sh` | Detect and kill stuck agents | | `check-agents.sh` | Quick status check | | `endorse-task.sh` | Create endorsement file for a task | | `esr-log.sh` | Update project ESR docs | | `eor-log.sh` | Write agent end-of-run log | | `fallback-swap.sh` | Model selection with fallback | | `assess-models.sh` | Weekly model benchmark | | `deploy-notify.sh` | CI/CD notification | ## Hard Rules ### ALWAYS - Present plan → **STOP** → wait for human approval → THEN spawn (separate turns!) - Use `spawn-batch.sh` for 2+ tasks, `spawn-agent.sh` for single tasks - Let scripts handle tmux, notifications, review, integration — don't bypass ### NEVER - Spawn agents without human endorsement - Present plan and spawn in the same message/turn - Write prompts before receiving endorsement - Use bare `claude --print` or background exec (bypasses entire pipeline) - Spawn agents in the orchestrator's workspace directory ## Heartbeat Checks Read `references/HEARTBEAT.md` for periodic checks: 1. Read `pending-notifications.txt` → send to human → clear 2. Run `pulse-check.sh` for stuck agents 3. Check `tmux ls` for completed agents 4. Report brief status if agents are running ## Further Reading - `references/ROLE.md` — Full role definition with lessons learned - `references/TOOLS.md` — Detailed script usage and prompt patterns

path: references/HEARTBEAT.md
content: # Swarm Lead — Heartbeat Checks

## On Every Heartbeat

### 1. Check Active Agents
```bash
tmux ls 2>/dev/null
```
- If agents running: check for stuck sessions (>45 min with no new commits)
- If stuck: run `pulse-check.sh` or check tmux pane output

### 2. Check Pending Notifications
```bash
cat ~/workspace/swarm/pending-notifications.txt 2>/dev/null
```
- If notifications pending: process them (merge PRs, notify WB, clean up)
- Clear processed notifications

### 3. Check Integration Watchers
```bash
ps aux | grep integration-watcher | grep -v grep
```
- If integration watcher running but all agents done: check if it's stuck
- If no watcher but agents finishing: alert — may need manual integration

### 4. Weekly Assessment (Mondays)
- Run `assess-models.sh` to check Codex/Gemini quota status
- Update duty table if needed
- Report any changes to WB

## Rules
- If agents are running fine: HEARTBEAT_OK
- If something needs attention: handle it, don't just report
- If waiting for WB input: skip swarm checks, HEARTBEAT_OK


path: references/ROLE.md
content: # Swarm Lead — Role Definition

## Identity
The AI Swarm Lead hat. A **senior staff engineer + PM** role. When WB asks me to build, fix, or ship — I wear this hat.

## Roles & Duty Table

| Role | Phase | Steps | Model Priority | Purpose |
|------|-------|-------|----------------|---------|
| 🏗 **Architect** | Phase 1 | 1-5 | Opus > o3 > Gemini Deep | Plan, research, spawn |
| 🔧 **Builder** | Phase 2 | 6 | Sonnet > Codex | Code the task |
| 👀 **Reviewer** | Phase 2 | 7 | Sonnet > Codex | Review + fix |
| 🔗 **Integrator** | Phase 3 | 8-11 | Opus > o3 > Gemini Deep | Merge, review cross-deps, ship |

## The 3-Phase Workflow

### Phase 1: PLAN (Architect — deep thinking)
**Steps 1-5: CONTEXT → RESEARCH → REFINE → PLAN → SPAWN**
Same agent throughout — maintains full context.
- Read project ESR + Obsidian + codebase
- Pressure-test approach, explore options
- Create prompts + tasks.json
- **Present plan to WB, wait for endorsement**
- Run `spawn-batch.sh` to deploy all agents

### Phase 2: BUILD (Builder + Reviewer — fast workers)
**Step 6: BUILD** — Builder works autonomously in tmux
- Implements task, writes work log, commits, pushes, creates PR

**Step 7: REVIEW+FIX** — Reviewer auto-spawns when builder exits
- Reads work log, reviews code, fixes issues (max 3 loops)
- Reviewer = Fixer (no separate agent, no context loss)

### Phase 3: SHIP (Integrator — deep thinking)
**Steps 8-11: INTEGRATE → MERGE → ESR → NOTIFY**
Same agent throughout — needs full context of ALL subteams.
- Reads ALL work logs, merges branches, resolves conflicts
- Cross-team dependency review
- Auto-merge to main, update ESR, notify WB

## Key Scripts

| Script | Purpose | Phase |
|--------|---------|-------|
| `spawn-batch.sh` | Deploy N agents + integration watcher | Phase 1 |
| `spawn-agent.sh` | Deploy single agent | Phase 1 |
| `notify-on-complete.sh` | Auto-chain reviewer after builder | Phase 2 |
| `integration-watcher.sh` | Poll + auto-merge when all done | Phase 3 |
| `assess-models.sh` | Test agents, update duty table | Background |
| `duty-cycle.sh` | 6-hourly rotation | Background |

## Hard Rules

### ALWAYS
1. Present plan to WB and wait for endorsement before spawning
2. Use `spawn-batch.sh` for 2+ parallel tasks
3. Include completion event in every agent prompt
4. Read project context before planning
5. Clean up worktrees after merging

### NEVER
1. Spawn agents without WB's endorsement
2. Use `spawn-agent.sh` individually for batch work
3. Use bare `claude --print` in background (bypasses tracking)
4. Spawn agents in `~/.openclaw/workspace/`
5. Promise timed checks without starting a watcher

## Lessons Learned
- `resourceConfigurations` required for Android i18n (resource shrinker strips locales)
- R8 breaks Gson TypeToken — use Moshi codegen instead
- Logcat is essential for Android debugging — always get runtime logs before guessing
- Separate fixer agents lose context — reviewer = fixer
- I am stateless — the watcher IS the check


path: references/TOOLS.md
content: # Swarm Lead — Tools Notes

## spawn-batch.sh Usage (Primary Tool)

```bash
# 1. Write task prompts to /tmp/
cat > /tmp/prompt-task1.md << 'PROMPT'
... detailed task description ...

When completely finished, run:
openclaw system event --text "Done: task-1 — brief summary" --mode now
PROMPT

# 2. Create tasks JSON
cat > /tmp/batch-tasks.json << 'JSON'
[
  {"id": "task-1", "description": "/tmp/prompt-task1.md", "agent": "claude", "model": "claude-sonnet-4-6"},
  {"id": "task-2", "description": "/tmp/prompt-task2.md", "agent": "codex", "model": "gpt-5.3-codex"}
]
JSON

# 3. Spawn batch (auto-endorses + auto-integration)
cd ~/workspace/swarm
bash spawn-batch.sh "/mnt/d/Startup projects/ProjectName" "batch-id" "Batch description" /tmp/batch-tasks.json
```

## Agent CLI Commands

| Agent | Non-Interactive Command |
|-------|------------------------|
| Claude | `claude --model X --dangerously-skip-permissions -p "prompt"` |
| Codex | `codex --model X --dangerously-bypass-approvals-and-sandbox "prompt"` |
| Gemini | `GEMINI_API_KEY=$KEY gemini --model X -p "prompt"` |

## Integration Conflict Resolution Patterns

| Conflict Type | Resolution |
|---------------|------------|
| i18n + UX changes | Keep UX styling, apply stringResource() extraction |
| DB migrations (Room) | Combine with sequential version numbers |
| Multiple feature branches | Merge in dependency order (smallest/independent first) |
| Build failures post-merge | Fix imports, remove duplicates, verify with build command |

## Agent Selection Guide

| Task Type | Agent | Model | Why |
|-----------|-------|-------|-----|
| Complex architecture | Claude | Opus | Best reasoning |
| Standard features | Codex | gpt-5.3-codex | Fast, parallel-friendly |
| Code review | Gemini | 2.5 Pro | Good at spotting issues |
| Quick fixes/UI tweaks | Claude | Sonnet | Speed + reliability |
| Integration/merge | Claude | Opus or Sonnet | Needs full context |
| Research/analysis | Claude | Opus | Deep thinking |

When Codex/Gemini are quota-blocked, fall back to Claude for all roles.

## Monitoring Commands

```bash
# Quick status of all agents
tmux ls

# Check specific agent
tmux capture-pane -t <session> -p | tail -20

# Check worktree progress
cd <worktree> && git log --oneline origin/main..HEAD

# Check for conflicts
git diff --name-only --diff-filter=U

# Check integration watcher
tail -20 ~/workspace/swarm/logs/integration-*.log
```


path: references/duty-table-template.json
content: {
  "assessedAt": "2026-03-24T18:00:47.171704-07:00",
  "nextAssessment": "2026-03-25T00:00:47.171704-07:00",
  "availableAgents": {
    "claude": {
      "cli": "claude",
      "auth": "oauth",
      "status": "active",
      "models": {
        "claude-opus-4-6": {
          "status": "available",
          "tier": "top",
          "testedAt": "2026-03-09"
        },
        "claude-sonnet-4-6": {
          "status": "available",
          "tier": "mid",
          "testedAt": "2026-03-09"
        }
      }
    },
    "codex": {
      "cli": "codex",
      "auth": "oauth",
      "status": "benched",
      "models": {
        "gpt-5.3-codex": {
          "status": "model-error",
          "tier": "top",
          "testedAt": "2026-03-22",
          "note": "not supported with ChatGPT account"
        }
      }
    },
    "gemini": {
      "cli": "gemini",
      "auth": "oauth",
      "status": "active",
      "models": {
        "gemini-3-flash-preview": {
          "status": "available",
          "tier": "mid",
          "testedAt": "2026-03-09"
        },
        "gemini-2.5-pro": {
          "status": "available",
          "tier": "top",
          "testedAt": "2026-03-09"
        },
        "gemini-2.5-flash": {
          "status": "available",
          "tier": "mid",
          "testedAt": "2026-03-09"
        }
      }
    }
  },
  "dutyTable": {
    "architect": {
      "agent": "claude",
      "model": "claude-opus-4-6",
      "reason": "architect role (auto-assessed)",
      "nonInteractiveCmd": "claude --model claude-opus-4-6 --permission-mode bypassPermissions --print"
    },
    "builder": {
      "agent": "codex",
      "model": "gpt-5.3-codex",
      "reason": "builder role (auto-assessed)",
      "nonInteractiveCmd": "codex --model gpt-5.3-codex --full-auto exec"
    },
    "reviewer": {
      "agent": "gemini",
      "model": "gemini-2.5-pro",
      "reason": "reviewer role (auto-assessed)",
      "nonInteractiveCmd": "gemini -m gemini-2.5-pro -p"
    },
    "integrator": {
      "agent": "claude",
      "model": "claude-opus-4-6",
      "reason": "integrator role (auto-assessed)",
      "nonInteractiveCmd": "claude --model claude-opus-4-6 --permission-mode bypassPermissions --print"
    }
  },
  "history": [
    {
      "date": "2026-03-09 12:44",
      "changes": "WB MANUAL OVERRIDE: New duty table. Architect=gemini-3-flash-preview (fallback Opus), Workhorse=claude-sonnet-4-6, Reviewer=gemini-2.5-pro (fallback Sonnet), Speedster/Integration=claude-sonnet-4-6. Codex tokens exhausted this week.",
      "dutyAssignments": "architect=gemini/3-flash-preview, workhorse=claude/sonnet-4-6, reviewer=gemini/2.5-pro, speedster=claude/sonnet-4-6"
    },
    {
      "date": "2026-03-09",
      "changes": "FALLBACK to Claude-heavy table (token/quota limits on Codex/Gemini)",
      "dutyAssignments": "architect=claude/opus-4-6, workhorse=claude/sonnet-4-6, reviewer=claude/sonnet-4-6, speedster=claude/sonnet-4-6"
    },
    {
      "date": "2026-03-12",
      "changes": "FALLBACK to Claude-heavy table (token/quota limits on Codex/Gemini)",
      "dutyAssignments": "architect=claude/opus-4-6, workhorse=claude/sonnet-4-6, reviewer=claude/sonnet-4-6, speedster=claude/sonnet-4-6"
    },
    {
      "date": "2026-03-22",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=available, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, workhorse=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-pro, speedster=claude/claude-sonnet-4-6"
    },
    {
      "date": "2026-03-22",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=available, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, workhorse=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-pro, speedster=claude/claude-sonnet-4-6"
    },
    {
      "date": "2026-03-23",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=unavailable, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, workhorse=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-flash, speedster=claude/claude-sonnet-4-6"
    },
    {
      "date": "2026-03-23",
      "changes": "REDESIGN v2: Renamed speedster\u2192integrator, upgraded integrator to Opus (deep thinking). 3 phases: Architect(1-5), Builder+Reviewer(6-7), Integrator(8-11).",
      "dutyAssignments": "architect=claude/opus-4-6, builder=claude/sonnet-4-6, reviewer=claude/sonnet-4-6, integrator=claude/opus-4-6"
    },
    {
      "date": "2026-03-24",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=unavailable, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, builder=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-flash, integrator=claude/claude-opus-4-6"
    },
    {
      "date": "2026-03-24",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=available, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, builder=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-pro, integrator=claude/claude-opus-4-6"
    },
    {
      "date": "2026-03-24",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=available, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, builder=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-pro, integrator=claude/claude-opus-4-6"
    },
    {
      "date": "2026-03-24",
      "changes": "Weekly assessment: claude/claude-sonnet-4-6=available, claude/claude-opus-4-6=available, gemini/gemini-2.5-pro=available, codex/gpt-5.3-codex=available, gemini/gemini-2.5-flash=available",
      "dutyAssignments": "architect=claude/claude-opus-4-6, builder=codex/gpt-5.3-codex, reviewer=gemini/gemini-2.5-pro, integrator=claude/claude-opus-4-6"
    }
  ],
  "manualOverride": {
    "enabled": true,
    "setBy": "WB",
    "note": "Mar 22 override: Claude-heavy. Codex benched (model errors). Gemini for reviewer only."
  }
}


path: scripts/assess-models.sh
content: #!/usr/bin/env bash
# assess-models.sh — Weekly model availability assessment
# Tests all configured agent CLIs and models, updates duty-table.json
#
# Strategy:
#   1. Test all models across all 3 agents (Claude, Codex, Gemini)
#   2. Assign optimal 3-vendor duty table (different vendor per role)
#   3. If any vendor hits token/quota limits mid-week → auto-fallback to Claude-heavy table
#
# Usage: assess-models.sh [--dry-run] [--fallback]
#   --dry-run:  test models but don't update duty-table.json
#   --fallback: force Claude-heavy fallback (for mid-week quota issues)

set -euo pipefail

# macOS compatibility: use gtimeout if timeout not available
if ! command -v timeout &>/dev/null && command -v gtimeout &>/dev/null; then
  timeout() { gtimeout "$@"; }
fi

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SCRIPT_DIR/swarm.conf" ]] && source "$SCRIPT_DIR/swarm.conf"
NOTIFY_TARGET="${SWARM_NOTIFY_TARGET:-}"
NOTIFY_CHANNEL="${SWARM_NOTIFY_CHANNEL:-telegram}"
DUTY_TABLE="$SCRIPT_DIR/duty-table.json"
RESULTS_LOG="$SCRIPT_DIR/assessment.log"
DRY_RUN=""
FORCE_FALLBACK=""

for arg in "$@"; do
  case "$arg" in
    --dry-run) DRY_RUN=1 ;;
    --fallback) FORCE_FALLBACK=1 ;;
  esac
done

[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" 2>/dev/null || true

echo "Model Assessment — $(date '+%Y-%m-%d %H:%M %Z')" | tee "$RESULTS_LOG"
echo "=========================================" | tee -a "$RESULTS_LOG"

# ============================================================
# FALLBACK: Claude-heavy duty table (high token limit)
# ============================================================
apply_fallback() {
  echo "" | tee -a "$RESULTS_LOG"
  echo "⚠️  FALLBACK MODE: Switching to Claude-heavy duty table" | tee -a "$RESULTS_LOG"

  if [[ -n "$DRY_RUN" ]]; then
    echo "Dry run — not updating duty-table.json"
    return
  fi

  python3 -c "
import json, datetime
with open('$DUTY_TABLE') as f: data = json.load(f)
now = datetime.datetime.now().astimezone()
data['assessedAt'] = now.isoformat()
# Re-assess in 3 days (not full week) to try optimal table sooner
data['nextAssessment'] = (now + datetime.timedelta(hours=6)).isoformat()
data['dutyTable'] = {
    'architect': {'agent': 'claude', 'model': 'claude-opus-4-6', 'reason': 'FALLBACK: High token limit, best reasoning', 'nonInteractiveCmd': 'claude --model claude-opus-4-6 --permission-mode bypassPermissions --print'},
    'builder': {'agent': 'claude', 'model': 'claude-sonnet-4-6', 'reason': 'FALLBACK: High token limit, reliable', 'nonInteractiveCmd': 'claude --model claude-sonnet-4-6 --permission-mode bypassPermissions --print'},
    'reviewer': {'agent': 'claude', 'model': 'claude-sonnet-4-6', 'reason': 'FALLBACK: High token limit, fast review', 'nonInteractiveCmd': 'claude --model claude-sonnet-4-6 --permission-mode bypassPermissions --print'},
    'integrator': {'agent': 'claude', 'model': 'claude-sonnet-4-6', 'reason': 'FALLBACK: High token limit, fastest reliable', 'nonInteractiveCmd': 'claude --model claude-sonnet-4-6 --permission-mode bypassPermissions --print'}
}
data['history'].append({
    'date': now.strftime('%Y-%m-%d'),
    'changes': 'FALLBACK to Claude-heavy table (token/quota limits on Codex/Gemini)',
    'dutyAssignments': 'architect=claude/opus-4-6, builder=claude/sonnet-4-6, reviewer=claude/sonnet-4-6, integrator=claude/sonnet-4-6'
})
with open('$DUTY_TABLE', 'w') as f: json.dump(data, f, indent=2)
print('duty-table.json updated (FALLBACK)')
" | tee -a "$RESULTS_LOG"

  # Notify
  if [[ -n "$NOTIFY_TARGET" ]]; then
    openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" \
      --message "⚠️ Swarm duty table → FALLBACK (Claude-heavy). Codex/Gemini hit limits. Will re-assess in 3 days." \
      2>/dev/null || echo "⚠️ Notification send failed" >> "$RESULTS_LOG"
  fi
}

if [[ -n "$FORCE_FALLBACK" ]]; then
  apply_fallback
  exit 0
fi

# ============================================================
# TEST ALL MODELS
# ============================================================
declare -A MODEL_STATUS

test_model() {
  local agent="$1" model="$2" cmd="$3"
  local tmpout result_file
  result_file=$(mktemp)
  echo -n "  Testing $agent / $model ... " | tee -a "$RESULTS_LOG"

  # Create a temp git repo for codex (codex requires a git repo)
  local tmpdir=""
  if [[ "$agent" == "codex" ]]; then
    tmpdir=$(mktemp -d)
    git init -q "$tmpdir" 2>/dev/null
    cmd="cd $tmpdir && $cmd"
  fi

  # Run from /tmp to avoid CLIs picking up workspace context.
  # Gemini needs stdin closed (hangs waiting for input otherwise).
  # Claude needs stdin open (fails with closed stdin).
  local pid exit_code=0
  if [[ "$agent" == "gemini" ]]; then
    bash -c "cd /tmp && $cmd" < /dev/null > "$result_file" 2>&1 &
  else
    bash -c "cd /tmp && $cmd" > "$result_file" 2>&1 &
  fi
  pid=$!

  # Wait up to 45 seconds
  local waited=0 running=1
  while (( running && waited < 45 )); do
    if ! kill -0 "$pid" 2>/dev/null; then
      running=0
    else
      sleep 1
      (( waited++ )) || true
    fi
  done

  if (( running )) && kill -0 "$pid" 2>/dev/null; then
    # Still running after timeout — force kill
    kill -9 "$pid" 2>/dev/null
    wait "$pid" 2>/dev/null || true
    echo "TIMEOUT" | tee -a "$RESULTS_LOG"
    MODEL_STATUS["${agent}/${model}"]="unavailable"
  else
    wait "$pid" 2>/dev/null
    exit_code=$?
    local result
    result=$(cat "$result_file")
    if [[ $exit_code -eq 0 ]] && ! echo "$result" | grep -qi "error\|quota\|unauthorized\|401\|429\|rate.limit\|exceeded\|capacity"; then
      echo "OK" | tee -a "$RESULTS_LOG"
      MODEL_STATUS["${agent}/${model}"]="available"
    else
      echo "FAIL: $(echo "$result" | tail -1)" | tee -a "$RESULTS_LOG"
      MODEL_STATUS["${agent}/${model}"]="unavailable"
    fi
  fi

  rm -f "$result_file" 2>/dev/null
  [[ -n "$tmpdir" ]] && rm -rf "$tmpdir" 2>/dev/null || true
}

PROBE="Reply with ONLY the word HELLO, nothing else."

echo "" | tee -a "$RESULTS_LOG"
echo "Claude Code (OAuth)" | tee -a "$RESULTS_LOG"
test_model "claude" "claude-opus-4-6" "claude --model claude-opus-4-6 --permission-mode bypassPermissions --print '$PROBE'"
test_model "claude" "claude-sonnet-4-6" "claude --model claude-sonnet-4-6 --permission-mode bypassPermissions --print '$PROBE'"

echo "" | tee -a "$RESULTS_LOG"
echo "Codex (OAuth/ChatGPT Plus)" | tee -a "$RESULTS_LOG"
test_model "codex" "gpt-5.3-codex" "codex exec '$PROBE'"

echo "" | tee -a "$RESULTS_LOG"
echo "Gemini (OAuth/Google)" | tee -a "$RESULTS_LOG"
test_model "gemini" "gemini-2.5-pro" "gemini -m gemini-2.5-pro -p '$PROBE'"
test_model "gemini" "gemini-2.5-flash" "gemini -m gemini-2.5-flash -p '$PROBE'"

echo "" | tee -a "$RESULTS_LOG"
echo "=========================================" | tee -a "$RESULTS_LOG"

# Print summary
echo "Results:" | tee -a "$RESULTS_LOG"
for key in "${!MODEL_STATUS[@]}"; do
  echo "  $key = ${MODEL_STATUS[$key]}" | tee -a "$RESULTS_LOG"
done

# ============================================================
# CHECK IF WE NEED FALLBACK
# ============================================================
CODEX_OK="${MODEL_STATUS[codex/gpt-5.3-codex]:-unavailable}"
GEMINI_PRO_OK="${MODEL_STATUS[gemini/gemini-2.5-pro]:-unavailable}"
GEMINI_FLASH_OK="${MODEL_STATUS[gemini/gemini-2.5-flash]:-unavailable}"
CLAUDE_SONNET_OK="${MODEL_STATUS[claude/claude-sonnet-4-6]:-unavailable}"
CLAUDE_OPUS_OK="${MODEL_STATUS[claude/claude-opus-4-6]:-unavailable}"

# If both Codex AND Gemini are down → fallback to Claude-heavy
if [[ "$CODEX_OK" != "available" && "$GEMINI_PRO_OK" != "available" && "$GEMINI_FLASH_OK" != "available" ]]; then
  echo "" | tee -a "$RESULTS_LOG"
  echo "Both Codex and Gemini unavailable → triggering fallback" | tee -a "$RESULTS_LOG"
  apply_fallback
  exit 0
fi

# ============================================================
# ASSIGN OPTIMAL 3-VENDOR DUTY TABLE
# ============================================================

# Architect: Claude Opus (always, it's the best reasoner)
ARCHITECT="claude/claude-opus-4-6"
[[ "$CLAUDE_OPUS_OK" != "available" ]] && ARCHITECT="claude/claude-sonnet-4-6"

# Workhorse: Codex preferred, fallback to Claude Sonnet
if [[ "$CODEX_OK" == "available" ]]; then
  BUILDER="codex/gpt-5.3-codex"
else
  BUILDER="claude/claude-sonnet-4-6"
  echo "⚠️ Codex unavailable, Claude Sonnet takes builder" | tee -a "$RESULTS_LOG"
fi

# Reviewer: Gemini Pro preferred, fallback to Flash, then Claude
if [[ "$GEMINI_PRO_OK" == "available" ]]; then
  REVIEWER="gemini/gemini-2.5-pro"
elif [[ "$GEMINI_FLASH_OK" == "available" ]]; then
  REVIEWER="gemini/gemini-2.5-flash"
else
  REVIEWER="claude/claude-sonnet-4-6"
  echo "⚠️ Gemini unavailable, Claude Sonnet takes reviewer" | tee -a "$RESULTS_LOG"
fi

# Speedster: Claude Sonnet (always — fast + high token limit)
INTEGRATOR="claude/claude-opus-4-6"

echo "" | tee -a "$RESULTS_LOG"
echo "Duty Assignments:" | tee -a "$RESULTS_LOG"
echo "  architect  = $ARCHITECT" | tee -a "$RESULTS_LOG"
echo "  builder  = $BUILDER" | tee -a "$RESULTS_LOG"
echo "  reviewer   = $REVIEWER" | tee -a "$RESULTS_LOG"
echo "  integrator  = $INTEGRATOR" | tee -a "$RESULTS_LOG"

[[ -n "$DRY_RUN" ]] && { echo "Dry run — not updating duty-table.json"; exit 0; }

# ============================================================
# UPDATE DUTY TABLE
# ============================================================
# Build model status summary from bash vars
MODEL_SUMMARY=""
for key in "${!MODEL_STATUS[@]}"; do
  MODEL_SUMMARY="${MODEL_SUMMARY}${key}=${MODEL_STATUS[$key]}, "
done
MODEL_SUMMARY="${MODEL_SUMMARY%, }"

python3 -c "
import json, datetime
with open('$DUTY_TABLE') as f: data = json.load(f)
now = datetime.datetime.now().astimezone()
data['assessedAt'] = now.isoformat()
data['nextAssessment'] = (now + datetime.timedelta(hours=6)).isoformat()

def cmd(a, m):
    if a == 'claude': return f'claude --model {m} --permission-mode bypassPermissions --print'
    if a == 'codex': return f'codex --model {m} --full-auto exec'
    if a == 'gemini': return f'gemini -m {m} -p'
    return ''

for role, am in {'architect':'$ARCHITECT','builder':'$BUILDER','reviewer':'$REVIEWER','integrator':'$INTEGRATOR'}.items():
    if am:
        a, m = am.split('/')
        data['dutyTable'][role] = {'agent': a, 'model': m, 'reason': role + ' role (auto-assessed)', 'nonInteractiveCmd': cmd(a, m)}

data['history'].append({
    'date': now.strftime('%Y-%m-%d'),
    'changes': 'Weekly assessment: $MODEL_SUMMARY',
    'dutyAssignments': 'architect=$ARCHITECT, builder=$BUILDER, reviewer=$REVIEWER, integrator=$INTEGRATOR'
})
with open('$DUTY_TABLE', 'w') as f: json.dump(data, f, indent=2)
print('duty-table.json updated')
"

# Notify
if [[ -n "$NOTIFY_TARGET" ]]; then
  openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" \
    --message "📊 Weekly model assessment complete. Duty: architect=$ARCHITECT, builder=$BUILDER, reviewer=$REVIEWER, integrator=$INTEGRATOR" \
    2>/dev/null || echo "Notification send failed" >> "$RESULTS_LOG"
fi

echo "" | tee -a "$RESULTS_LOG"
echo "Assessment complete at $(date '+%H:%M %Z')" | tee -a "$RESULTS_LOG"


path: scripts/check-agents.sh
content: #!/usr/bin/env bash
# Universal check-agents.sh — Monitor all active agents across ALL projects
# Run via cron or heartbeat to babysit agents
# Now also generates EOR logs on completion detection

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
TASKS_FILE="$SWARM_DIR/active-tasks.json"
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"

[[ -f "$TASKS_FILE" ]] || exit 0

python3 << PYEOF
import json, subprocess, os, sys

script_dir = os.path.dirname(os.path.abspath("$SWARM_DIR/check-agents.sh"))
# Use the known swarm dir
script_dir = "$SWARM_DIR"
tasks_path = os.path.join(script_dir, "active-tasks.json")
notify_path = os.path.join(script_dir, "pending-notifications.txt")
eor_script = os.path.join(script_dir, "eor-log.sh")

with open(tasks_path) as f:
    data = json.load(f)

changed = False
notifications = []

for task in data.get("tasks", []):
    if task["status"] != "running":
        continue
    
    session = task["tmuxSession"]
    
    # Check if tmux session is still alive
    result = subprocess.run(["tmux", "has-session", "-t", session], 
                          capture_output=True, text=True)
    
    if result.returncode != 0:
        # Session ended
        branch = task.get("branch", "")
        project_dir = task.get("projectDir", task.get("worktree", "."))
        task_id = task.get("id", "unknown")
        agent = task.get("agent", "swarm")
        
        # --- Generate EOR log ---
        if os.path.isfile(eor_script) and os.path.isdir(project_dir):
            try:
                eor_result = subprocess.run(
                    ["bash", eor_script, project_dir, task_id, "", agent],
                    capture_output=True, text=True, timeout=30
                )
                if eor_result.returncode == 0:
                    print(f"EOR log generated for {task_id}")
                else:
                    print(f"EOR log failed for {task_id}: {eor_result.stderr[:200]}")
            except Exception as e:
                print(f"EOR log error for {task_id}: {e}")
        
        # Check for PR
        pr_num = ""
        try:
            pr_result = subprocess.run(
                ["gh", "pr", "list", "--head", branch, "--json", "number", "-q", ".[0].number"],
                capture_output=True, text=True, cwd=project_dir, timeout=15
            )
            pr_num = pr_result.stdout.strip()
        except:
            pass
        
        task["status"] = "done"
        task["completedAt"] = int(subprocess.run(["date", "+%s"], capture_output=True, text=True).stdout.strip()) * 1000
        
        if pr_num:
            task["pr"] = int(pr_num)
            notifications.append(f"✅ Agent {session} completed task '{task_id}' for {task.get('project','?')}. PR #{pr_num} ready for review. EOR log written.")
        else:
            notifications.append(f"✅ Agent {session} completed task '{task_id}' for {task.get('project','?')}. Check branch {branch}. EOR log written.")
        
        changed = True

if changed:
    with open(tasks_path, "w") as f:
        json.dump(data, f, indent=2)

if notifications:
    with open(notify_path, "a") as f:
        for n in notifications:
            f.write(n + "\n")
    print("\n".join(notifications))
else:
    print("All agents still running or no active tasks.")
PYEOF


path: scripts/check-completions.sh
content: #!/usr/bin/env bash
# check-completions.sh — Cron-based backup notifier
# Runs every 5 minutes via cron. Checks pending-notifications.txt for unsent notifications.
# This is a BACKUP — primary notifications come from notify-on-complete.sh watchers.
# If watchers die (process killed, WSL restart), this catches what they missed.

# Ensure PATH includes openclaw + node (cron has minimal PATH)
export PATH="$(dirname "$(command -v openclaw 2>/dev/null || echo /usr/local/bin/openclaw)"):/usr/local/bin:/usr/bin:/bin:$PATH"

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SWARM_DIR/swarm.conf" ]] && source "$SWARM_DIR/swarm.conf"
NOTIFY_TARGET="${SWARM_NOTIFY_TARGET:-}"
NOTIFY_CHANNEL="${SWARM_NOTIFY_CHANNEL:-telegram}"
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"
SENT_FILE="$SWARM_DIR/sent-notifications.txt"

# Create sent tracker if missing
touch "$SENT_FILE"

# Nothing to do if no pending notifications
[[ ! -s "$NOTIFY_FILE" ]] && exit 0

# Skip if no notification target configured
[[ -z "$NOTIFY_TARGET" ]] && exit 0

# Check each line — if not in sent file, send it
while IFS= read -r line; do
  [[ -z "$line" ]] && continue

  # Skip if already sent
  if grep -Fxq "$line" "$SENT_FILE" 2>/dev/null; then
    continue
  fi

  # Send notification
  openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" --message "📋 $line" 2>/dev/null && {
    echo "$line" >> "$SENT_FILE"
  }

  # Rate limit: 1 message per 2 seconds
  sleep 2
done < "$NOTIFY_FILE"

# Keep sent file from growing forever (last 200 lines)
tail -200 "$SENT_FILE" > "$SENT_FILE.tmp" && mv "$SENT_FILE.tmp" "$SENT_FILE"


path: scripts/deploy-notify.sh
content: #!/bin/bash
# Usage: deploy-notify.sh <project-dir> <commit-hash>
# Polls GitHub Actions for build result and sends Telegram notification

PROJECT_DIR="$1"
COMMIT="$2"

# Load swarm config (notifications, etc.)
SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SWARM_DIR/swarm.conf" ]] && source "$SWARM_DIR/swarm.conf"
NOTIFY_TARGET="${SWARM_NOTIFY_TARGET:-}"
NOTIFY_CHANNEL="${SWARM_NOTIFY_CHANNEL:-telegram}"

send_notify() {
  local msg="$1"
  [[ -z "$NOTIFY_TARGET" ]] && return 0
  openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" --message "$msg" 2>/dev/null || true
}

cd "$PROJECT_DIR" || exit 1

# Get repo name from git remote
REPO=$(git remote get-url origin | sed 's|.*github.com[:/]||' | sed 's|\.git$||')
PROJECT_NAME=$(basename "$PROJECT_DIR")

echo "Watching GitHub Actions for $REPO @ $COMMIT..."

# Poll for up to 15 minutes
for i in $(seq 1 30); do
    sleep 30

    # Check if any workflow run matches this commit
    STATUS=$(gh run list --commit "$COMMIT" --limit 1 --json status,conclusion -q '.[0]' 2>/dev/null)

    if [ -z "$STATUS" ]; then
        continue
    fi

    CONCLUSION=$(echo "$STATUS" | jq -r '.conclusion // empty')
    RUN_STATUS=$(echo "$STATUS" | jq -r '.status // empty')

    if [ "$RUN_STATUS" = "completed" ]; then
        if [ "$CONCLUSION" = "success" ]; then
            send_notify "✅ CI Build PASSED for $PROJECT_NAME (${COMMIT:0:7}) — github.com/$REPO/actions"
            echo "Build passed!"
            exit 0
        else
            RUN_URL=$(gh run list --commit "$COMMIT" --limit 1 --json url -q '.[0].url' 2>/dev/null)
            send_notify "❌ CI Build FAILED for $PROJECT_NAME (${COMMIT:0:7}) — $RUN_URL"
            echo "Build failed!"
            exit 1
        fi
    fi
done

send_notify "⏰ CI Build timed out for $PROJECT_NAME (${COMMIT:0:7}) — check GitHub Actions manually"
echo "Timed out waiting for build"
exit 2


path: scripts/duty-cycle.sh
content: #!/usr/bin/env bash
# duty-cycle.sh — run every 6 hours
# 1) Re-assess model availability
# 2) Check usage/quota pressure (current + week windows)
# 3) If any duty provider >=95% used, rotate that role to another brand
#    (priority: codex > claude > gemini)

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SWARM_DIR/swarm.conf" ]] && source "$SWARM_DIR/swarm.conf"
NOTIFY_TARGET="${SWARM_NOTIFY_TARGET:-}"
NOTIFY_CHANNEL="${SWARM_NOTIFY_CHANNEL:-telegram}"
LOG_DIR="$SWARM_DIR/logs"
mkdir -p "$LOG_DIR"

DUTY_TABLE="$SWARM_DIR/duty-table.json"
ROUTER_DUTY="${ROUTER_DUTY:-}"

# 1) Re-assess availability (best effort)
"$SWARM_DIR/assess-models.sh" >> "$LOG_DIR/assessment.log" 2>&1 || true

# Use freshest duty table source available
if [[ -f "$ROUTER_DUTY" && "$ROUTER_DUTY" -nt "$DUTY_TABLE" ]]; then
  cp "$ROUTER_DUTY" "$DUTY_TABLE" 2>/dev/null || true
fi

USAGE_JSON="$(mktemp)"
openclaw status --usage --json > "$USAGE_JSON" 2>/dev/null || echo '{}' > "$USAGE_JSON"

python3 - <<'PY' "$DUTY_TABLE" "$USAGE_JSON" "$ROUTER_DUTY"
import json, sys, datetime
from copy import deepcopy

duty_path, usage_path, router_path = sys.argv[1], sys.argv[2], sys.argv[3]

with open(duty_path, 'r', encoding='utf-8') as f:
    duty = json.load(f)
try:
    with open(usage_path, 'r', encoding='utf-8') as f:
        usage = json.load(f)
except Exception:
    usage = {}

providers = {}
for p in usage.get('usage', {}).get('providers', []) or []:
    name = p.get('provider')
    if not name:
        continue
    err = (p.get('error') or '').lower()
    windows = p.get('windows') or []
    if err and any(x in err for x in ['429', 'rate', 'quota', 'limit', 'exceed']):
        providers[name] = 100
    elif windows:
        providers[name] = max(int(w.get('usedPercent', 0) or 0) for w in windows)
    else:
        providers[name] = 0

agent_to_provider = {
    'claude': 'anthropic',
    'codex': 'openai-codex',
    'gemini': 'google',
}

def usage_pct_for_agent(agent: str) -> int:
    provider = agent_to_provider.get(agent, '')
    if provider in providers:
        return providers[provider]
    # fallback: fuzzy search for provider keys containing fragments
    if agent == 'gemini':
        for k,v in providers.items():
            if 'gemini' in k or 'google' in k:
                return v
    return 0

threshold = 95

def model_for(agent: str, role: str, available: dict):
    models = (available.get(agent, {}) or {}).get('models', {}) or {}
    def has(m):
        return models.get(m, {}).get('status') == 'available'
    if agent == 'codex':
        return 'gpt-5.3-codex' if has('gpt-5.3-codex') or True else 'gpt-5.3-codex'
    if agent == 'claude':
        if role == 'architect' and has('claude-opus-4-6'):
            return 'claude-opus-4-6'
        return 'claude-sonnet-4-6' if has('claude-sonnet-4-6') else 'claude-opus-4-6'
    if agent == 'gemini':
        if role == 'reviewer' and models.get('gemini-2.5-pro', {}).get('status') == 'available':
            return 'gemini-2.5-pro'
        return 'gemini-2.5-flash' if models.get('gemini-2.5-flash', {}).get('status') == 'available' else 'gemini-2.5-pro'
    return ''

def cmd_for(agent: str, model: str):
    if agent == 'claude':
        return f'claude --model {model} --permission-mode bypassPermissions --print'
    if agent == 'codex':
        return f'codex exec --model {model} --full-auto -'
    if agent == 'gemini':
        return f'gemini -m {model} -p'
    return ''

available = duty.get('availableAgents', {})
old = deepcopy(duty.get('dutyTable', {}))
new = deepcopy(old)
changes = []

# provider preference when rotating: codex > claude > gemini
preferred_agents = ['codex', 'claude', 'gemini']

for role, cfg in old.items():
    cur_agent = cfg.get('agent', '')
    cur_pct = usage_pct_for_agent(cur_agent)
    if cur_pct < threshold:
        continue

    # rotate this role to a DIFFERENT brand not nearing threshold
    for candidate in preferred_agents:
        if candidate == cur_agent:
            continue
        pct = usage_pct_for_agent(candidate)
        if pct >= threshold:
            continue
        model = model_for(candidate, role, available)
        if not model:
            continue
        new[role] = {
            'agent': candidate,
            'model': model,
            'reason': f'Auto-rotated: previous provider near limit ({cur_pct}% >= {threshold}%).',
            'nonInteractiveCmd': cmd_for(candidate, model),
        }
        changes.append((role, f"{cur_agent}/{cfg.get('model','?')}", f"{candidate}/{model}", cur_pct, pct))
        break

if changes:
    duty['dutyTable'] = new

# Keep 6-hour cadence metadata
now = datetime.datetime.now().astimezone()
duty['assessedAt'] = now.isoformat()
duty['nextAssessment'] = (now + datetime.timedelta(hours=6)).isoformat()

hist = duty.setdefault('history', [])
if changes:
    hist.append({
        'date': now.strftime('%Y-%m-%d %H:%M'),
        'changes': '; '.join([f"{r}: {a} -> {b} (from {cp}%)" for r,a,b,cp,_ in changes]),
        'dutyAssignments': ', '.join([f"{r}={duty['dutyTable'][r]['agent']}/{duty['dutyTable'][r]['model']}" for r in duty['dutyTable']]),
    })

with open(duty_path, 'w', encoding='utf-8') as f:
    json.dump(duty, f, indent=2)
    f.write('\n')

# Sync router copy if present
try:
    if router_path and router_path.strip():
        with open(router_path, 'w', encoding='utf-8') as f:
            json.dump(duty, f, indent=2)
            f.write('\n')
except Exception:
    pass

print('CHANGES', len(changes))
for c in changes:
    print('ROTATE', c)
PY

# Optional notify when changed
if grep -q "^ROTATE" <(python3 - <<'PY' "$DUTY_TABLE"
import json,sys
j=json.load(open(sys.argv[1]))
h=j.get('history',[])
if h:
    last=h[-1].get('changes','')
    if '->' in last:
        print('ROTATE')
PY
); then
  LAST_CHANGE=$(python3 - <<'PY' "$DUTY_TABLE"
import json,sys
j=json.load(open(sys.argv[1]))
h=j.get('history',[])
print(h[-1].get('changes','')) if h else print('')
PY
)
  if [[ -n "$NOTIFY_TARGET" ]]; then
    openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" --message "⚙️ Duty auto-rotation applied: $LAST_CHANGE" 2>/dev/null || true
  fi
fi

rm -f "$USAGE_JSON"


path: scripts/endorse-task.sh
content: #!/usr/bin/env bash
# endorse-task.sh — Create endorsement file for a task
# Called by the orchestrator AFTER WB approves the plan
#
# Usage: endorse-task.sh <task-id> [endorsement-note]

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
TASK_ID="${1:?Usage: endorse-task.sh <task-id> [endorsement-note]}"
NOTE="${2:-Endorsed}"

ENDORSE_DIR="$SWARM_DIR/endorsements"
ENDORSE_FILE="$ENDORSE_DIR/${TASK_ID}.endorsed"

mkdir -p "$ENDORSE_DIR"

cat > "$ENDORSE_FILE" << EOF
Task: $TASK_ID
Endorsed: $(date '+%Y-%m-%d %H:%M:%S')
Note: $NOTE
EOF

echo "✅ Task '$TASK_ID' endorsed → $ENDORSE_FILE"


path: scripts/eor-log.sh
content: #!/usr/bin/env bash
# eor-log.sh v2 — Single executive EOR log per project
#
# Usage: eor-log.sh <project-dir> [summary-text]
#
# Maintains ONE living EOR log per project at:
#   - <project>/docs/EOR.md (codebase, pushed to GitHub)
#   - $OBSIDIAN_BASE/<Project>/EOR.md (Obsidian vault, if OBSIDIAN_BASE is set)
#
# This is NOT a per-agent log. It's an executive summary maintained by the orchestrator.
# Contains: what's been achieved, latest updates, what's next, actionable levers.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SCRIPT_DIR/swarm.conf" ]] && source "$SCRIPT_DIR/swarm.conf"

PROJECT_DIR="${1:?Usage: eor-log.sh <project-dir> [summary-text]}"
SUMMARY="${2:-}"

PROJECT_NAME=$(basename "$PROJECT_DIR")
TIMESTAMP=$(date "+%Y-%m-%d %H:%M")
DATE=$(date "+%Y-%m-%d")

# Determine paths
CODEBASE_EOR="$PROJECT_DIR/docs/EOR.md"
OBSIDIAN_BASE="${OBSIDIAN_BASE:-}"

# Create codebase docs directory
mkdir -p "$PROJECT_DIR/docs"

# If EOR doesn't exist yet, create template
if [[ ! -f "$CODEBASE_EOR" ]]; then
  cat > "$CODEBASE_EOR" << TEMPLATE
# $PROJECT_NAME — Executive Summary (EOR)
*Last updated: $TIMESTAMP*

## What We've Built
<!-- High-level summary of what exists -->

## Latest Updates
<!-- Most recent session's work -->

## What's Next
<!-- Prioritized next steps -->

## Actionable Levers
<!-- What would it take to make this succeed? Key decisions, resources, blockers -->

## Learnings
<!-- Technical and product lessons learned -->

---
*This is a living document maintained by the orchestrator. Updated after each work session.*
TEMPLATE
fi

# If summary provided, append as an update
if [[ -n "$SUMMARY" ]]; then
  # Update the "Last updated" timestamp
  sed -i "s/\*Last updated:.*\*/*Last updated: $TIMESTAMP*/" "$CODEBASE_EOR"

  # Append update entry
  cat >> "$CODEBASE_EOR" << UPDATE

### Update: $TIMESTAMP
$SUMMARY
UPDATE
fi

echo "[eor] Updated EOR for $PROJECT_NAME at $TIMESTAMP"
echo "  → $CODEBASE_EOR"

# Sync to Obsidian (optional)
if [[ -n "$OBSIDIAN_BASE" ]]; then
  OBSIDIAN_EOR="$OBSIDIAN_BASE/$PROJECT_NAME/EOR.md"
  mkdir -p "$OBSIDIAN_BASE/$PROJECT_NAME"
  cp "$CODEBASE_EOR" "$OBSIDIAN_EOR"
  echo "  → $OBSIDIAN_EOR"
fi


path: scripts/esr-log.sh
content: #!/usr/bin/env bash
# esr-log.sh — Executive Summary Report per project
#
# Usage: esr-log.sh <project-dir> [summary-text]
#
# Maintains ONE living ESR per project at:
#   - <project>/docs/ESR.md (codebase, pushed to GitHub)
#   - $OBSIDIAN_BASE/<Project>/ESR.md (Obsidian vault, if OBSIDIAN_BASE is set)
#
# This is NOT a per-agent log. It's an executive summary maintained by the orchestrator.
# Contains: what's been achieved, latest updates, what's next, actionable levers.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SCRIPT_DIR/swarm.conf" ]] && source "$SCRIPT_DIR/swarm.conf"

PROJECT_DIR="${1:?Usage: esr-log.sh <project-dir> [summary-text]}"
SUMMARY="${2:-}"

PROJECT_NAME=$(basename "$PROJECT_DIR")
TIMESTAMP=$(date "+%Y-%m-%d %H:%M")
DATE=$(date "+%Y-%m-%d")

# Determine paths
CODEBASE_ESR="$PROJECT_DIR/docs/ESR.md"
OBSIDIAN_BASE="${OBSIDIAN_BASE:-}"

# Migrate old EOR → ESR if needed
OLD_EOR="$PROJECT_DIR/docs/EOR.md"
if [[ -f "$OLD_EOR" ]] && [[ ! -f "$CODEBASE_ESR" ]]; then
  mv "$OLD_EOR" "$CODEBASE_ESR"
  sed -i 's/Executive Summary (EOR)/Executive Summary Report (ESR)/g' "$CODEBASE_ESR"
  sed -i 's/EOR/ESR/g' "$CODEBASE_ESR"
  echo "[esr] Migrated EOR → ESR for $PROJECT_NAME"
fi

# Migrate old Obsidian EOR → ESR if Obsidian is configured
if [[ -n "$OBSIDIAN_BASE" ]]; then
  OLD_OBS_EOR="$OBSIDIAN_BASE/$PROJECT_NAME/EOR.md"
  OBSIDIAN_ESR="$OBSIDIAN_BASE/$PROJECT_NAME/ESR.md"
  if [[ -f "$OLD_OBS_EOR" ]] && [[ ! -f "$OBSIDIAN_ESR" ]]; then
    mv "$OLD_OBS_EOR" "$OBSIDIAN_ESR"
    sed -i 's/Executive Summary (EOR)/Executive Summary Report (ESR)/g' "$OBSIDIAN_ESR"
    sed -i 's/EOR/ESR/g' "$OBSIDIAN_ESR"
  fi
fi

# Create codebase docs directory
mkdir -p "$PROJECT_DIR/docs"

# If ESR doesn't exist yet, create template
if [[ ! -f "$CODEBASE_ESR" ]]; then
  cat > "$CODEBASE_ESR" << TEMPLATE
# $PROJECT_NAME — Executive Summary Report (ESR)
*Last updated: $TIMESTAMP*

## What We've Built
<!-- High-level summary of what exists -->

## Latest Updates
<!-- Most recent session's work -->

## What's Next
<!-- Prioritized next steps -->

## Actionable Levers
<!-- What would it take to make this succeed? Key decisions, resources, blockers -->

## Learnings
<!-- Technical and product lessons learned -->

---
*This is a living document maintained by the orchestrator. Updated after each work session.*
TEMPLATE
fi

# If summary provided, append as an update
if [[ -n "$SUMMARY" ]]; then
  # Update the "Last updated" timestamp
  sed -i "s/\*Last updated:.*\*/*Last updated: $TIMESTAMP*/" "$CODEBASE_ESR"

  # Append update entry
  cat >> "$CODEBASE_ESR" << UPDATE

### Update: $TIMESTAMP
$SUMMARY
UPDATE
fi

echo "[esr] Updated ESR for $PROJECT_NAME at $TIMESTAMP"
echo "  → $CODEBASE_ESR"

# Sync to Obsidian (optional)
if [[ -n "$OBSIDIAN_BASE" ]]; then
  OBSIDIAN_ESR="$OBSIDIAN_BASE/$PROJECT_NAME/ESR.md"
  mkdir -p "$OBSIDIAN_BASE/$PROJECT_NAME"
  cp "$CODEBASE_ESR" "$OBSIDIAN_ESR"
  echo "  → $OBSIDIAN_ESR"
fi


path: scripts/fallback-swap.sh
content: #!/usr/bin/env bash
# fallback-swap.sh — Try primary model for a role; if it fails, test fallback.
# If fallback works, swap primary↔fallback in duty-table.json for this week.
#
# Usage: fallback-swap.sh <role>
# Outputs the working command to stdout (caller captures it).
# Exit 0 = got a working model; Exit 1 = both failed.
#
# Roles: architect, builder, reviewer, integrator

set -euo pipefail

ROLE="${1:?Usage: fallback-swap.sh <role>}"
SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
DUTY_TABLE="$SWARM_DIR/duty-table.json"
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"

if [[ ! -f "$DUTY_TABLE" ]]; then
  echo "[fallback-swap] No duty table found" >&2
  exit 1
fi

# Read primary and fallback for the role
read_role() {
  python3 -c "
import json, sys
with open('$DUTY_TABLE') as f: d = json.load(f)
role = d.get('dutyTable', {}).get('$ROLE', {})
kind = sys.argv[1]
if kind == 'primary':
    print(role.get('agent', ''), role.get('model', ''), role.get('nonInteractiveCmd', ''), sep='|')
elif kind == 'fallback':
    fb = role.get('fallback', {})
    if fb:
        # Build cmd from fallback
        agent = fb.get('agent', '')
        model = fb.get('model', '')
        if agent == 'claude':
            cmd = f'claude --model {model} --permission-mode bypassPermissions --print'
        elif agent == 'gemini':
            cmd = f'gemini -m {model}'  # -y -p added by caller wrapper
        elif agent == 'codex':
            cmd = f'codex --model {model} -q'
        else:
            cmd = ''
        print(agent, model, cmd, sep='|')
    else:
        print('||')
" "$1" 2>/dev/null
}

IFS='|' read -r P_AGENT P_MODEL P_CMD <<< "$(read_role primary)"
IFS='|' read -r F_AGENT F_MODEL F_CMD <<< "$(read_role fallback)"

if [[ -z "$P_AGENT" || -z "$P_MODEL" ]]; then
  echo "[fallback-swap] No primary defined for role: $ROLE" >&2
  exit 1
fi

# Test primary
echo "[fallback-swap] Testing primary: $P_AGENT/$P_MODEL for $ROLE..." >&2
if "$SWARM_DIR/try-model.sh" "$P_AGENT" "$P_MODEL" 2>/dev/null; then
  echo "[fallback-swap] ✅ Primary works: $P_AGENT/$P_MODEL" >&2
  echo "$P_CMD"
  exit 0
fi

echo "[fallback-swap] ❌ Primary failed: $P_AGENT/$P_MODEL" >&2

# No fallback defined?
if [[ -z "$F_AGENT" || -z "$F_MODEL" ]]; then
  echo "[fallback-swap] No fallback defined for $ROLE — both failed" >&2
  exit 1
fi

# Test fallback
echo "[fallback-swap] Testing fallback: $F_AGENT/$F_MODEL..." >&2
if ! "$SWARM_DIR/try-model.sh" "$F_AGENT" "$F_MODEL" 2>/dev/null; then
  echo "[fallback-swap] ❌ Fallback also failed: $F_AGENT/$F_MODEL" >&2
  TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
  echo "🚨 [$TIMESTAMP] BOTH primary ($P_AGENT/$P_MODEL) and fallback ($F_AGENT/$F_MODEL) failed for $ROLE!" >> "$NOTIFY_FILE"
  exit 1
fi

echo "[fallback-swap] ✅ Fallback works: $F_AGENT/$F_MODEL — SWAPPING in duty table" >&2

# Swap primary ↔ fallback in duty-table.json
python3 << PYSWAP
import json
from datetime import datetime

with open('$DUTY_TABLE') as f:
    d = json.load(f)

role = d['dutyTable']['$ROLE']
old_primary = {'agent': role['agent'], 'model': role['model']}
fallback = role.get('fallback', {})

# Swap: fallback becomes primary, old primary becomes fallback
role['agent'] = fallback['agent']
role['model'] = fallback['model']

# Rebuild nonInteractiveCmd for new primary
if fallback['agent'] == 'claude':
    role['nonInteractiveCmd'] = f"claude --model {fallback['model']} --permission-mode bypassPermissions --print"
elif fallback['agent'] == 'gemini':
    role['nonInteractiveCmd'] = f"gemini -m {fallback['model']}"
elif fallback['agent'] == 'codex':
    role['nonInteractiveCmd'] = f"codex --model {fallback['model']} -q"

role['fallback'] = old_primary
role['reason'] = f"AUTO-SWAPPED {datetime.now():%Y-%m-%d %H:%M}: {old_primary['model']} failed, {fallback['model']} promoted"

# Log the swap
d.setdefault('history', []).append({
    'date': f"{datetime.now():%Y-%m-%d %H:%M}",
    'changes': f"AUTO-SWAP for $ROLE: {old_primary['agent']}/{old_primary['model']} failed → {fallback['agent']}/{fallback['model']} promoted to primary",
    'dutyAssignments': f"$ROLE={fallback['agent']}/{fallback['model']} (was {old_primary['agent']}/{old_primary['model']})"
})

with open('$DUTY_TABLE', 'w') as f:
    json.dump(d, f, indent=2)

print(f"Swapped: {old_primary['model']} → fallback, {fallback['model']} → primary")
PYSWAP

TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "🔄 [$TIMESTAMP] AUTO-SWAP for $ROLE: $P_MODEL failed → $F_MODEL promoted to primary (old primary demoted to fallback)" >> "$NOTIFY_FILE"

# Output the working command (now the former fallback)
echo "$F_CMD"
exit 0


path: scripts/integration-watcher.sh
content: #!/usr/bin/env bash
# integration-watcher.sh v2 — Waits for all subteams, reads their work logs, runs integration review
#
# Usage: integration-watcher.sh <project-dir> <description> <session1> <session2> ... <sessionN>
#
# Each session has its own work log at /tmp/worklog-{session}.md
# Integration reviewer reads ALL work logs for cross-subteam context
# Integration reviewer also maintains its own log at /tmp/worklog-integration-{project}.md
# All work logs are persisted to <project>/docs/history/ for orchestrator traceability

# Ensure PATH includes openclaw + node (for cron/detached contexts)
export PATH="$(dirname "$(command -v openclaw 2>/dev/null || echo /usr/local/bin/openclaw)"):/usr/local/bin:/usr/bin:/bin:$PATH"

set -euo pipefail

PROJECT_DIR="${1:?Usage: integration-watcher.sh <project-dir> <description> <session1> [session2] ...}"
DESCRIPTION="${2:?Missing description}"
shift 2
SESSIONS=("$@")

MAX_PARALLEL=10
if [[ ${#SESSIONS[@]} -lt 1 ]]; then
  echo "Error: need at least 1 session" >&2; exit 1
fi
if [[ ${#SESSIONS[@]} -gt $MAX_PARALLEL ]]; then
  echo "Error: max $MAX_PARALLEL parallel (got ${#SESSIONS[@]})" >&2; exit 1
fi

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SWARM_DIR/swarm.conf" ]] && source "$SWARM_DIR/swarm.conf"
NOTIFY_TARGET="${SWARM_NOTIFY_TARGET:-}"
NOTIFY_CHANNEL="${SWARM_NOTIFY_CHANNEL:-telegram}"
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"
POLL_INTERVAL=60
MAX_REVIEW_LOOPS=3
PROJECT_NAME=$(basename "$PROJECT_DIR")
INTEGRATION_LOG="/tmp/worklog-integration-${PROJECT_NAME}.md"

send_telegram() {
  local msg="$1"
  if [[ -z "$NOTIFY_TARGET" ]]; then
    echo "[integration] No NOTIFY_TARGET configured, skipping notification"
    return 0
  fi
  openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" --message "$msg" 2>/dev/null || {
    echo "[integration] ⚠️ Notification send failed"
    echo "$msg" >> "$NOTIFY_FILE"
  }
}

echo "[integration] Watching ${#SESSIONS[@]} subteams: ${SESSIONS[*]}"

# ============================================================
# PHASE 1: Wait for all subteams (builder + review chains)
# ============================================================
all_done() {
  # Check that NO tmux sessions exist for any subteam (builder, review, or fix)
  local active_sessions
  active_sessions=$(tmux ls 2>/dev/null | cut -d: -f1 || echo "")
  
  for sess in "${SESSIONS[@]}"; do
    # Check if builder session still exists
    if echo "$active_sessions" | grep -qE "^${sess}$"; then return 1; fi
    # Check if any review/fix chain sessions exist
    if echo "$active_sessions" | grep -qE "^${sess}-review-"; then return 1; fi
    if echo "$active_sessions" | grep -qE "^${sess}-fix-"; then return 1; fi
  done
  
  # Extra safety: wait for notify-on-complete.sh processes to finish
  for sess in "${SESSIONS[@]}"; do
    if ps aux 2>/dev/null | grep "notify-on-complete.sh.*${sess}" | grep -v grep > /dev/null; then
      return 1
    fi
  done
  
  return 0
}

WAIT_START=$(date +%s)
MAX_WAIT=7200

while ! all_done; do
  sleep "$POLL_INTERVAL"
  ELAPSED=$(( $(date +%s) - WAIT_START ))
  if [[ $ELAPSED -gt $MAX_WAIT ]]; then
    send_telegram "⚠️ Integration watcher timed out after 2h."
    exit 1
  fi
done

# Stabilization: wait 30s then re-check (catches late-spawning review sessions)
echo "[integration] All subteams appear done. Stabilization wait (30s)..."
sleep 30
if ! all_done; then
  echo "[integration] Late sessions detected after stabilization, continuing to wait..."
  while ! all_done; do
    sleep "$POLL_INTERVAL"
    ELAPSED=$(( $(date +%s) - WAIT_START ))
    if [[ $ELAPSED -gt $MAX_WAIT ]]; then
      send_telegram "⚠️ Integration watcher timed out after 2h."
      exit 1
    fi
  done
fi

TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[integration] ✅ All ${#SESSIONS[@]} subteams fully complete at $TIMESTAMP"

# ============================================================
# PHASE 2: Collect all subteam work logs
# ============================================================
COMBINED_LOGS=""
for sess in "${SESSIONS[@]}"; do
  SUBLOG="/tmp/worklog-${sess}.md"
  if [[ -f "$SUBLOG" ]]; then
    COMBINED_LOGS="$COMBINED_LOGS

========================================
## Subteam: $sess
========================================
$(cat "$SUBLOG")"
  else
    COMBINED_LOGS="$COMBINED_LOGS

========================================
## Subteam: $sess
========================================
(No work log found)"
  fi
done

# Initialize integration log
cat > "$INTEGRATION_LOG" << ILOG
# Integration Log: $DESCRIPTION
**Project:** $PROJECT_NAME
**Subteams:** ${SESSIONS[*]}
**Started:** $TIMESTAMP

## Subteam Summaries
$COMBINED_LOGS

---
## Integration Review
ILOG

send_telegram "🔗 All ${#SESSIONS[@]} subteams done. Starting integration review with work logs..."

# ============================================================
# PHASE 3: Integration review+fix loop (reads all work logs)
# ============================================================
cd "$PROJECT_DIR"
REVIEW_DEPTH=$(( ${#SESSIONS[@]} * 4 ))
[[ $REVIEW_DEPTH -lt 5 ]] && REVIEW_DEPTH=5
SESSION_LIST="${SESSIONS[*]}"

LOOP=0
PASS="False"
while [[ $LOOP -lt $MAX_REVIEW_LOOPS ]]; do
  LOOP=$((LOOP + 1))
  INTEG_SESSION="integration-${LOOP}"
  VERDICT_FILE="/tmp/review-verdict-${INTEG_SESSION}.json"
  rm -f "$VERDICT_FILE"

  send_telegram "🔗 Integration review+fix $LOOP/$MAX_REVIEW_LOOPS..."

  # Write integration reviewer prompt to file (avoids shell quoting issues)
  INTEG_PROMPT_FILE="/tmp/integration-prompt-${INTEG_SESSION}.md"
  INTEG_LOG_CONTENT=$(cat "$INTEGRATION_LOG")

  cat > "$INTEG_PROMPT_FILE" << INTEG_PROMPT_EOF
You are an INTEGRATION REVIEWER at $PROJECT_DIR. Round $LOOP of $MAX_REVIEW_LOOPS.

## CONTEXT — All Subteam Work Logs:

The following work logs were written by builder and reviewer agents. They document exactly what was changed, why, and any concerns flagged. USE THEM to understand the full scope of changes before reviewing code.

$INTEG_LOG_CONTENT

---

## YOUR TASK:

### STEP 1: Understand the scope
Read the work logs above first. They tell you:
- Which files each subteam touched and why
- What decisions/tradeoffs were made
- Any known issues or integration concerns flagged by builders/reviewers

### STEP 2: Review for CROSS-SUBTEAM issues
  git log --oneline -${REVIEW_DEPTH}
  git diff HEAD~${REVIEW_DEPTH} --stat

Focus on issues that arise from MULTIPLE agents working in parallel:
1. DEPENDENCY CONFLICTS — subteams importing/changing the same modules differently
2. DUPLICATE CODE — parallel agents solving the same problem independently
3. IMPORT/BUILD CONFLICTS — conflicting type definitions, missing exports
4. SHARED STATE — incompatible changes to global state, singletons, event buses
5. API CONTRACT BREAKS — one subteam changed an interface another depends on
6. CONFIG COMPATIBILITY — manifest, build config, environment conflicts

### STEP 3: Fix any issues directly
  git add -A && git commit -m "integration fix (round $LOOP): <description>" && git push 2>/dev/null

### STEP 4: Update the integration log
Append your findings to $INTEGRATION_LOG:

cat >> $INTEGRATION_LOG << 'ILOG_EOF'

### Integration Round $LOOP
- **Timestamp:** $(date '+%Y-%m-%d %H:%M:%S')
- **Cross-team conflicts found:** <list or "None">
- **Duplicated code merged:** <list or "None">
- **Build verified:** <pass/fail>
- **Fixes applied:** <describe or "None needed">
- **Remaining concerns:** <anything for the orchestrator>
ILOG_EOF

### STEP 5: Write verdict file (MANDATORY)
  echo '{"pass": true, "summary": "YOUR SUMMARY HERE", "issues_remaining": ""}' > $VERDICT_FILE
Or if issues remain:
  echo '{"pass": false, "summary": "YOUR SUMMARY", "issues_remaining": "DESCRIBE REMAINING"}' > $VERDICT_FILE
INTEG_PROMPT_EOF

  # Use fallback-swap to get the working integrator command (tests primary, swaps if needed)
  SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
  INTEG_CMD=$("$SWARM_DIR/fallback-swap.sh" integrator 2>/dev/null) || INTEG_CMD="claude --model claude-opus-4-6 --permission-mode bypassPermissions --print"

  INTEG_WRAPPER="/tmp/integ-wrapper-${INTEG_SESSION}.sh"
  if echo "$INTEG_CMD" | grep -q "^gemini"; then
    GEMINI_BASE=$(echo "$INTEG_CMD" | sed 's/ *$//')
    cat > "$INTEG_WRAPPER" << IWRAP_EOF
#!/usr/bin/env bash
cd "$PROJECT_DIR"
$GEMINI_BASE -y -p "\$(cat $INTEG_PROMPT_FILE)"
IWRAP_EOF
  else
    cat > "$INTEG_WRAPPER" << IWRAP_EOF
#!/usr/bin/env bash
cd "$PROJECT_DIR"
$INTEG_CMD "\$(cat $INTEG_PROMPT_FILE)"
IWRAP_EOF
  fi
  chmod +x "$INTEG_WRAPPER"
  tmux new-session -d -s "$INTEG_SESSION" -c "$PROJECT_DIR" "bash $INTEG_WRAPPER"

  while tmux has-session -t "$INTEG_SESSION" 2>/dev/null; do
    sleep "$POLL_INTERVAL"
  done

  # Check verdict file; auto-infer if missing (watcher decides)
  if [[ ! -f "$VERDICT_FILE" ]]; then
    LATEST_MSG=$(cd "$PROJECT_DIR" && git log --oneline -1 2>/dev/null | head -1)
    INTEG_LOG_UPDATED=false
    if [[ -f "$INTEGRATION_LOG" ]] && grep -q "Integration Round $LOOP\|integration.*round $LOOP" "$INTEGRATION_LOG" 2>/dev/null; then
      INTEG_LOG_UPDATED=true
    fi

    if echo "$LATEST_MSG" | grep -qi "integration\|review\|fix\|pass\|clean"; then
      send_telegram "✅ Integration review passed (round $LOOP): fixed issues → $LATEST_MSG"
      PASS="True"
    elif [[ "$INTEG_LOG_UPDATED" == "true" ]]; then
      send_telegram "✅ Integration review passed (round $LOOP): no cross-team issues found"
      PASS="True"
    else
      send_telegram "✅ Integration review auto-passed (round $LOOP): clean exit, no issues indicated"
      PASS="True"
    fi
    break
  fi

  PASS=$(python3 -c "import json; print(json.load(open('$VERDICT_FILE')).get('pass', True))" 2>/dev/null || echo "True")
  SUMMARY=$(python3 -c "import json; print(json.load(open('$VERDICT_FILE')).get('summary', 'No summary'))" 2>/dev/null || echo "")

  if [[ "$PASS" == "True" ]]; then
    send_telegram "✅ Integration review PASSED (round $LOOP): $SUMMARY"
    cd "$PROJECT_DIR" && git commit --allow-empty -m "Integration review $LOOP: passed — $SUMMARY" && git push origin main 2>/dev/null || true
    break
  fi

  send_telegram "🔄 Integration round $LOOP: issues remain — $SUMMARY"
done

if [[ "$PASS" != "True" ]]; then
  send_telegram "⚠️ Integration review maxed ($MAX_REVIEW_LOOPS). Manual review needed."
fi

# ============================================================
# PHASE 4: Persist integration log to project history
# ============================================================
HISTORY_DIR="$PROJECT_DIR/docs/history"
mkdir -p "$HISTORY_DIR"
HISTORY_FILE="$HISTORY_DIR/$(date +%Y-%m-%d)-integration.md"

# Append if multiple integrations same day, otherwise create
if [[ -f "$HISTORY_FILE" ]]; then
  echo "" >> "$HISTORY_FILE"
  echo "---" >> "$HISTORY_FILE"
  echo "" >> "$HISTORY_FILE"
  cat "$INTEGRATION_LOG" >> "$HISTORY_FILE"
else
  cp "$INTEGRATION_LOG" "$HISTORY_FILE"
fi

# Also keep a copy in swarm logs
cp "$INTEGRATION_LOG" "$SWARM_DIR/logs/integration-${PROJECT_NAME}-$(date +%Y%m%d-%H%M).md" 2>/dev/null || true

# Persist subteam work logs to history too
for sess in "${SESSIONS[@]}"; do
  SUBLOG="/tmp/worklog-${sess}.md"
  if [[ -f "$SUBLOG" ]]; then
    cp "$SUBLOG" "$HISTORY_DIR/$(date +%Y-%m-%d)-${sess}.md"
  fi
done

# ============================================================
# PHASE 5: Auto-update ESR
# ============================================================
INTEG_SUMMARY="${SUMMARY:-Integration review completed}"
if [[ -x "$SWARM_DIR/esr-log.sh" ]]; then
  "$SWARM_DIR/esr-log.sh" "$PROJECT_DIR" "### Integration Review — $(date '+%Y-%m-%d %H:%M')
**Subteams:** ${SESSIONS[*]}
**Result:** $INTEG_SUMMARY" 2>/dev/null || echo "[integration] ⚠️ ESR update failed (non-fatal)"
fi

# Commit history + ESR together
cd "$PROJECT_DIR"
git add docs/history/ docs/ESR.md 2>/dev/null || true
git commit -m "docs: auto-update ESR + persist integration history ($(date +%Y-%m-%d))" 2>/dev/null || true
git push origin main 2>/dev/null || git push 2>/dev/null || true

echo "[integration] Work logs + ESR persisted to $HISTORY_DIR"

exit 0


path: scripts/notify-on-complete.sh
content: #!/usr/bin/env bash
# notify-on-complete.sh v3 — Subteam workflow with shared work log
#
# Usage: notify-on-complete.sh <session-name> <description> [--review <project-dir>] [--max-loops N]
#
# WORKFLOW:
#   Builder starts → creates work log at /tmp/worklog-{session}.md
#   Builder finishes → watcher spawns Reviewer (who also fixes)
#   Reviewer reads work log, reviews code, fixes issues, updates work log
#   If reviewer can't fully resolve → spawns new reviewer with updated work log
#   Max loops (default 3). Work log persists across the chain for context.
#   On completion: work log saved to <project>/docs/history/, ESR auto-updated.

# Ensure PATH includes openclaw + node (for cron/detached contexts)
export PATH="$(dirname "$(command -v openclaw 2>/dev/null || echo /usr/local/bin/openclaw)"):/usr/local/bin:/usr/bin:/bin:$PATH"

set -euo pipefail

SESSION="${1:?Usage: notify-on-complete.sh <session-name> <description> [--review <project-dir>] [--max-loops N]}"
DESCRIPTION="${2:-$SESSION completed}"
REVIEW_PROJECT=""
MAX_LOOPS=3
shift 2 || true

while [[ $# -gt 0 ]]; do
  case "$1" in
    --review) REVIEW_PROJECT="${2:?--review requires a project directory}"; shift 2 ;;
    --max-loops) MAX_LOOPS="${2:?--max-loops requires a number}"; shift 2 ;;
    *) shift ;;
  esac
done

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
[[ -f "$SWARM_DIR/swarm.conf" ]] && source "$SWARM_DIR/swarm.conf"
NOTIFY_TARGET="${SWARM_NOTIFY_TARGET:-}"
NOTIFY_CHANNEL="${SWARM_NOTIFY_CHANNEL:-telegram}"
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"
POLL_INTERVAL=60
WORKLOG="/tmp/worklog-${SESSION}.md"
BASE_SESSION="$SESSION"

echo "[watcher] Watching session: $SESSION (poll: ${POLL_INTERVAL}s, review: ${REVIEW_PROJECT:-none}, max-loops: $MAX_LOOPS)"

send_telegram() {
  local msg="$1"
  if [[ -z "$NOTIFY_TARGET" ]]; then
    echo "[watcher] No NOTIFY_TARGET configured, skipping notification"
    return 0
  fi
  openclaw message send --channel "$NOTIFY_CHANNEL" --target "$NOTIFY_TARGET" --message "$msg" 2>/dev/null || {
    echo "[watcher] ⚠️ Notification send failed"
    echo "$msg" >> "$NOTIFY_FILE"
  }
}

check_stuck() {
  local sess="$1"
  CURRENT=$(tmux capture-pane -t "$sess" -p 2>/dev/null | tail -5 || echo "")
  if echo "$CURRENT" | grep -qi "Failed to login\|API key must be set\|How would you like to authenticate\|Permission denied\|rate limit\|429\|quota\|token limit\|exceeded.*limit\|capacity.*exceeded"; then
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

    # Detect token/quota limit → trigger fallback to Claude-heavy duty table
    if echo "$CURRENT" | grep -qi "rate limit\|429\|quota\|token limit\|exceeded.*limit\|capacity.*exceeded"; then
      MSG="⚠️ [$TIMESTAMP] Agent $sess hit TOKEN/QUOTA LIMIT — killing + triggering fallback"
      send_telegram "$MSG"
      echo "$MSG" >> "$NOTIFY_FILE"
      tmux kill-session -t "$sess" 2>/dev/null || true
      # Trigger fallback duty table
      "$SWARM_DIR/assess-models.sh" --fallback 2>/dev/null || true
      return 0
    fi

    MSG="⚠️ [$TIMESTAMP] Agent $sess appears STUCK — killing it"
    send_telegram "$MSG"
    echo "$MSG" >> "$NOTIFY_FILE"
    tmux kill-session -t "$sess" 2>/dev/null || true
    return 0
  fi
  return 1
}

check_functional_done() {
  local sess="$1"
  local idle_threshold=600  # 10 minutes
  local log_file="$SWARM_DIR/logs/${sess}.log"

  [[ -f "$log_file" ]] || return 1

  local now last_mod idle_s
  now=$(date +%s)
  last_mod=$(stat -c %Y "$log_file" 2>/dev/null || echo 0)
  [[ "$last_mod" -gt 0 ]] || return 1
  idle_s=$(( now - last_mod ))

  # Only treat as functionally complete if stale + final milestones are present in log.
  if [[ "$idle_s" -gt "$idle_threshold" ]] && \
     grep -Eqi "Work log finalized|PR (opened|created):|Branch pushed:|Pushed: .*feat/|Commit:\s+[0-9a-f]{7,}" "$log_file"; then
    local mins=$(( idle_s / 60 ))
    local ts
    ts=$(date '+%Y-%m-%d %H:%M:%S')
    local msg="ℹ️ [$ts] Agent $sess appears functionally complete (idle ${mins}m after final milestones) — auto-closing session"
    send_telegram "$msg"
    echo "$msg" >> "$NOTIFY_FILE"
    tmux kill-session -t "$sess" 2>/dev/null || true
    return 0
  fi

  return 1
}

# ============================================================
# persist_and_update_esr — called on ALL exit paths
# ============================================================
persist_and_update_esr() {
  local final_summary="${1:-Completed}"

  # Skip if no project dir
  if [[ -z "$REVIEW_PROJECT" || ! -d "$REVIEW_PROJECT" ]]; then
    return 0
  fi

  # 1. Persist work log to project history
  HISTORY_DIR="$REVIEW_PROJECT/docs/history"
  mkdir -p "$HISTORY_DIR"
  if [[ -f "$WORKLOG" ]]; then
    cp "$WORKLOG" "$HISTORY_DIR/$(date +%Y-%m-%d)-${BASE_SESSION}.md"
  fi

  # 2. Auto-update ESR
  if [[ -x "$SWARM_DIR/esr-log.sh" ]]; then
    "$SWARM_DIR/esr-log.sh" "$REVIEW_PROJECT" "### $BASE_SESSION — $(date '+%Y-%m-%d %H:%M')
$final_summary" 2>/dev/null || echo "[watcher] ⚠️ ESR update failed (non-fatal)"
  fi

  # 3. Commit history + ESR together
  cd "$REVIEW_PROJECT"
  git add docs/history/ docs/ESR.md 2>/dev/null || true
  git commit -m "docs: auto-update ESR + persist worklog for $BASE_SESSION" 2>/dev/null || true
  git push 2>/dev/null || true

  echo "[watcher] Work log + ESR persisted for $BASE_SESSION"
}

# ============================================================
# PHASE 1: Wait for builder to finish
# ============================================================
while true; do
  sleep "$POLL_INTERVAL"
  if ! tmux has-session -t "$SESSION" 2>/dev/null; then
    break
  fi
  # Auto-close sessions that are functionally done but left hanging in the terminal.
  check_functional_done "$SESSION" && break
  check_stuck "$SESSION" && exit 1
done

TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[watcher] Builder finished at $TIMESTAMP"
# NOTE: Do NOT write to pending-notifications.txt here.
# The review pass (or no-review completion below) is the meaningful milestone.
# Writing here causes duplicate notifications when heartbeat picks up the file.

# Initialize work log if builder didn't create one
if [[ ! -f "$WORKLOG" ]]; then
  cat > "$WORKLOG" << WLOG
# Work Log: $SESSION
## Task: $DESCRIPTION
---
### Builder Phase
- **Started:** $(date '+%Y-%m-%d %H:%M:%S')
- **Completed:** $TIMESTAMP
- **Status:** Builder finished. Awaiting review.
WLOG
fi

# If no review requested, just notify, persist, and exit
if [[ -z "$REVIEW_PROJECT" || ! -d "$REVIEW_PROJECT" ]]; then
  send_telegram "✅ $DESCRIPTION"
  persist_and_update_esr "$DESCRIPTION"
  exit 0
fi

# ============================================================
# PHASE 2: Review+Fix loop (reviewer does both)
# ============================================================
LOOP=0
PASS="True"
SUMMARY=""
REMAINING=""

while [[ $LOOP -lt $MAX_LOOPS ]]; do
  LOOP=$((LOOP + 1))
  REVIEW_SESSION="${BASE_SESSION}-review-${LOOP}"
  VERDICT_FILE="/tmp/review-verdict-${REVIEW_SESSION}.json"
  rm -f "$VERDICT_FILE"

  send_telegram "🔍 Review+fix round $LOOP/$MAX_LOOPS for $BASE_SESSION..."

  # Read current work log content to pass as context
  WORKLOG_CONTENT=""
  if [[ -f "$WORKLOG" ]]; then
    WORKLOG_CONTENT=$(cat "$WORKLOG")
  fi

  # Write reviewer prompt to file (avoids shell quoting hell with large work logs)
  REVIEW_PROMPT_FILE="/tmp/review-prompt-${REVIEW_SESSION}.md"
  cat > "$REVIEW_PROMPT_FILE" << REVIEW_PROMPT_EOF
You are a code REVIEWER+FIXER at $REVIEW_PROJECT. Round $LOOP of $MAX_LOOPS.

## WORK LOG (context from the builder and previous reviewers):

$WORKLOG_CONTENT

---

## YOUR TASK:

The work log above tells you what the builder changed and why. Use it to focus your review — you know exactly which files were touched, what decisions were made, and what the builder flagged as concerns.

### STEP 1: Review
Start by understanding the scope from the work log, then verify with git:
  git log --oneline -5
  git diff HEAD~1 --stat
  git diff HEAD~1

Check for:
1. Compilation errors, missing imports, undefined references
2. Logic bugs, edge cases, or incorrect assumptions noted in the work log
3. Platform-specific issues (permissions, lifecycle, manifest)
4. Security issues
5. Performance issues (memory leaks, unnecessary allocations)
6. Dead code or leftover artifacts
7. Any "Known issues" or "Integration notes" the builder flagged

### STEP 2: Fix
Fix any issues you find directly. Do not leave them for another agent.
  git add -A && git commit -m "review+fix round $LOOP: <brief description>" && git push 2>/dev/null

### STEP 3: Update the work log
Append your review findings to $WORKLOG. Be specific — the integration agent reads this next.

cat >> $WORKLOG << 'WLOG_EOF'

### Review+Fix Round $LOOP
- **Reviewer:** $REVIEW_SESSION
- **Timestamp:** $(date '+%Y-%m-%d %H:%M:%S')
- **Files reviewed:** <list files you checked>
- **Issues found:** <describe issues or "None">
- **Fixes applied:** <describe fixes or "None needed">
- **Build status:** <ran npx vite build / gradle build? pass/fail>
- **Remaining concerns:** <anything the integrator should watch for>
WLOG_EOF

### STEP 4: Write verdict file (MANDATORY)
Run this exact command (fill in your values):
  echo '{"pass": true, "summary": "YOUR SUMMARY HERE", "issues_remaining": ""}' > $VERDICT_FILE
Or if issues remain that you couldn't fix:
  echo '{"pass": false, "summary": "YOUR SUMMARY", "issues_remaining": "DESCRIBE REMAINING"}' > $VERDICT_FILE
REVIEW_PROMPT_EOF

  # Use fallback-swap to get the working reviewer command (tests primary, swaps if needed)
  REVIEW_CMD=$("$SWARM_DIR/fallback-swap.sh" reviewer 2>/dev/null) || REVIEW_CMD="claude --model claude-sonnet-4-6 --permission-mode bypassPermissions --print"

  # Build the reviewer wrapper — gemini needs -y -p (yolo + prompt arg), claude uses -p
  REVIEW_WRAPPER="/tmp/review-wrapper-${REVIEW_SESSION}.sh"
  if echo "$REVIEW_CMD" | grep -q "^gemini"; then
    # Gemini: use -y (auto-approve tools) -p (prompt arg) for shell access
    GEMINI_BASE=$(echo "$REVIEW_CMD" | sed 's/ *$//')
    cat > "$REVIEW_WRAPPER" << WRAPPER_EOF
#!/usr/bin/env bash
cd "$REVIEW_PROJECT"
$GEMINI_BASE -y -p "\$(cat $REVIEW_PROMPT_FILE)"
WRAPPER_EOF
  else
    cat > "$REVIEW_WRAPPER" << WRAPPER_EOF
#!/usr/bin/env bash
cd "$REVIEW_PROJECT"
$REVIEW_CMD "\$(cat $REVIEW_PROMPT_FILE)"
WRAPPER_EOF
  fi
  chmod +x "$REVIEW_WRAPPER"
  tmux new-session -d -s "$REVIEW_SESSION" -c "$REVIEW_PROJECT" "bash $REVIEW_WRAPPER"

  # Wait for reviewer
  while tmux has-session -t "$REVIEW_SESSION" 2>/dev/null; do
    sleep "$POLL_INTERVAL"
    check_stuck "$REVIEW_SESSION" && break
  done

  # Read verdict — auto-infer if file missing (Option 2: watcher decides)
  if [[ ! -f "$VERDICT_FILE" ]]; then
    # Reviewer exited without writing verdict. Watcher infers the result:
    # 1. Check if reviewer made a commit (= found & fixed issues)
    # 2. Check if work log was updated (= reviewer ran and wrote findings)
    # 3. Clean exit + no commit = nothing to fix = pass

    LATEST_MSG=$(cd "$REVIEW_PROJECT" && git log --oneline -1 2>/dev/null | head -1)
    WORKLOG_UPDATED=false
    if [[ -f "$WORKLOG" ]] && grep -q "Review.*Round $LOOP\|review.*round $LOOP\|Round $LOOP" "$WORKLOG" 2>/dev/null; then
      WORKLOG_UPDATED=true
    fi

    if echo "$LATEST_MSG" | grep -qi "review\|fix\|pass\|clean"; then
      # Reviewer committed fixes → pass (issues found and fixed)
      SUMMARY="Review passed — reviewer fixed issues (commit: $LATEST_MSG)"
      PASS="True"
      send_telegram "✅ $BASE_SESSION review passed (round $LOOP): fixed issues → $LATEST_MSG"
    elif [[ "$WORKLOG_UPDATED" == "true" ]]; then
      # Reviewer updated work log but no commit → reviewed, nothing to fix → pass
      SUMMARY="Review passed — reviewer found no issues (work log updated, no fixes needed)"
      PASS="True"
      send_telegram "✅ $BASE_SESSION review passed (round $LOOP): no issues found"
    else
      # Reviewer exited cleanly without verdict, commit, or work log update
      # Clean exit from Claude = it finished its work. Most likely: reviewed, found nothing, didn't write verdict.
      SUMMARY="Review passed — reviewer exited cleanly (auto-pass: clean exit, no issues indicated)"
      PASS="True"
      send_telegram "✅ $BASE_SESSION review auto-passed (round $LOOP): clean exit, no issues indicated"
    fi

    # NOTE: send_telegram() already handles fallback to NOTIFY_FILE on failure.
    # Do NOT write to NOTIFY_FILE here — causes duplicate notifications.
    echo "" >> "$WORKLOG"
    echo "### Review Round $LOOP" >> "$WORKLOG"
    echo "- Verdict: $SUMMARY" >> "$WORKLOG"
    # Trigger CI/CD build notification
    if [ -n "$REVIEW_PROJECT" ]; then
      LATEST_COMMIT=$(cd "$REVIEW_PROJECT" && git rev-parse HEAD 2>/dev/null)
      if [ -n "$LATEST_COMMIT" ]; then
        nohup "$SWARM_DIR/deploy-notify.sh" "$REVIEW_PROJECT" "$LATEST_COMMIT" \
            >> "$SWARM_DIR/logs/cicd-$(date +%Y%m%d).log" 2>&1 &
      fi
    fi
    break
  fi

  PASS=$(python3 -c "import json; print(json.load(open('$VERDICT_FILE')).get('pass', True))" 2>/dev/null || echo "True")
  SUMMARY=$(python3 -c "import json; print(json.load(open('$VERDICT_FILE')).get('summary', 'No summary'))" 2>/dev/null || echo "")
  REMAINING=$(python3 -c "import json; print(json.load(open('$VERDICT_FILE')).get('issues_remaining', ''))" 2>/dev/null || echo "")

  if [[ "$PASS" == "True" ]]; then
    send_telegram "✅ $BASE_SESSION review passed (round $LOOP): $SUMMARY"
    # NOTE: send_telegram() already handles fallback to NOTIFY_FILE on failure.
    # Trigger CI/CD build notification
    if [ -n "$REVIEW_PROJECT" ]; then
      LATEST_COMMIT=$(cd "$REVIEW_PROJECT" && git rev-parse HEAD 2>/dev/null)
      if [ -n "$LATEST_COMMIT" ]; then
        nohup "$SWARM_DIR/deploy-notify.sh" "$REVIEW_PROJECT" "$LATEST_COMMIT" \
            >> "$SWARM_DIR/logs/cicd-$(date +%Y%m%d).log" 2>&1 &
      fi
    fi
    break
  fi

  # Failed — log it and loop (next reviewer gets updated work log automatically)
  send_telegram "🔄 $BASE_SESSION review round $LOOP: issues remain — $SUMMARY"
done

if [[ "$PASS" != "True" ]]; then
  send_telegram "⚠️ $BASE_SESSION hit max review loops ($MAX_LOOPS). Remaining: $REMAINING"
  echo "" >> "$WORKLOG"
  echo "### ⚠️ Max Loops Reached" >> "$WORKLOG"
  echo "- Unresolved: $REMAINING" >> "$WORKLOG"
fi

# ============================================================
# PHASE 3: Persist work log + send shipped summary (ALWAYS runs)
# ============================================================
persist_and_update_esr "$SUMMARY"

# ============================================================
# STEP 11: NOTIFY — Clean "shipped" summary for WB
# ============================================================
# Read the work log summary section for a human-friendly shipped message.
# Only send if we have a meaningful work log summary (avoids redundancy
# with the review pass notification which already has review details).
SHIPPED_SUMMARY=""
if [[ -f "$WORKLOG" ]]; then
  # Extract the Summary section from work log (bullet points of what changed)
  SHIPPED_SUMMARY=$(sed -n '/^## Summary/,/^## /p' "$WORKLOG" 2>/dev/null | head -20 | grep -E '^\s*-' | head -6)
fi

if [[ -n "$SHIPPED_SUMMARY" ]]; then
  # We have a real work log summary — send the shipped message with details
  SHIP_MSG="🚀 ${BASE_SESSION} shipped!
${SHIPPED_SUMMARY}"
  send_telegram "$SHIP_MSG"
fi
# If no work log summary, skip — the review pass notification already covered it.

exit 0


path: scripts/pulse-check.sh
content: #!/usr/bin/env bash
# pulse-check.sh — Detect stuck agents and notify
# Run via cron every 60 minutes when agents are active
#
# Stuck detection:
# 1. Check if tmux session exists but agent output hasn't changed in 30+ min
# 2. Check for known stuck patterns (auth prompts, waiting for input, error loops)
# 3. Kill stuck agents and notify

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"
PULSE_STATE="$SWARM_DIR/pulse-state.json"
LOGS_DIR="$SWARM_DIR/logs"

mkdir -p "$LOGS_DIR"

# Initialize pulse state if missing
[[ -f "$PULSE_STATE" ]] || echo '{}' > "$PULSE_STATE"

# Get all agent tmux sessions
AGENT_SESSIONS=$(tmux ls 2>/dev/null | grep -E "^(claude|codex|gemini)-" | cut -d: -f1 || true)

if [[ -z "$AGENT_SESSIONS" ]]; then
  echo "No agent sessions running."
  # Check for pending notifications that haven't been sent
  if [[ -s "$NOTIFY_FILE" ]]; then
    echo "PENDING_NOTIFICATIONS"
    cat "$NOTIFY_FILE"
  fi
  exit 0
fi

STUCK_PATTERNS=(
  "Failed to login"
  "API key must be set"
  "Authentication required"
  "Enter your API key"
  "How would you like to authenticate"
  "Press Enter to continue"
  "Do you want to proceed"
  "y/n"
  "Permission denied"
  "rate limit"
  "429 Too Many Requests"
  "SIGTERM"
  "Error: EACCES"
)

NOW=$(date +%s)
IDLE_DONE_THRESHOLD=600  # 10 minutes

for SESSION in $AGENT_SESSIONS; do
  echo "Checking: $SESSION"
  
  # Capture current pane content
  CURRENT_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null || echo "CAPTURE_FAILED")
  CURRENT_HASH=$(echo "$CURRENT_OUTPUT" | md5sum | cut -d' ' -f1)
  
  # Get last known hash
  LAST_HASH=$(python3 -c "
import json
with open('$PULSE_STATE') as f: d = json.load(f)
print(d.get('$SESSION', {}).get('hash', ''))
" 2>/dev/null || echo "")
  
  LAST_CHECK=$(python3 -c "
import json
with open('$PULSE_STATE') as f: d = json.load(f)
print(d.get('$SESSION', {}).get('timestamp', 0))
" 2>/dev/null || echo "0")
  
  # Check for stuck patterns
  IS_STUCK="no"
  STUCK_REASON=""
  for PATTERN in "${STUCK_PATTERNS[@]}"; do
    if echo "$CURRENT_OUTPUT" | grep -qi "$PATTERN"; then
      IS_STUCK="yes"
      STUCK_REASON="$PATTERN"
      break
    fi
  done
  
  # Check if log indicates functional completion but terminal session is hanging idle (>10m)
  LOG_FILE="$LOGS_DIR/${SESSION}.log"
  if [[ -f "$LOG_FILE" ]]; then
    LOG_LAST_MOD=$(stat -c %Y "$LOG_FILE" 2>/dev/null || echo 0)
    if [[ "$LOG_LAST_MOD" -gt 0 ]]; then
      LOG_IDLE=$(( NOW - LOG_LAST_MOD ))
      if [[ $LOG_IDLE -gt $IDLE_DONE_THRESHOLD ]] && grep -Eqi "Work log finalized|PR (opened|created):|Branch pushed:|Pushed: .*feat/|Commit:\s+[0-9a-f]{7,}" "$LOG_FILE"; then
        IS_STUCK="functional_done"
        STUCK_REASON="Functionally complete and idle for $(( LOG_IDLE / 60 )) minutes"
      fi
    fi
  fi

  # Check if output hasn't changed in 30+ minutes
  if [[ "$CURRENT_HASH" == "$LAST_HASH" && "$LAST_HASH" != "" && "$IS_STUCK" == "no" ]]; then
    ELAPSED=$(( NOW - LAST_CHECK ))
    if [[ $ELAPSED -gt 1800 ]]; then  # 30 minutes
      IS_STUCK="yes"
      STUCK_REASON="No output change for $(( ELAPSED / 60 )) minutes"
    fi
  fi
  
  # Update state
  python3 -c "
import json
with open('$PULSE_STATE') as f: d = json.load(f)
d['$SESSION'] = {'hash': '$CURRENT_HASH', 'timestamp': $NOW, 'stuck': '$IS_STUCK' == 'yes'}
with open('$PULSE_STATE', 'w') as f: json.dump(d, f, indent=2)
" 2>/dev/null
  
  if [[ "$IS_STUCK" == "functional_done" ]]; then
    echo "ℹ️  FUNCTIONALLY DONE: $SESSION — $STUCK_REASON"
    tmux kill-session -t "$SESSION" 2>/dev/null || true
    echo "✅ Agent $SESSION appears functionally complete and was auto-closed. Reason: $STUCK_REASON" >> "$NOTIFY_FILE"
    echo "   → Auto-closed stale completed session and queued completion notification"
  elif [[ "$IS_STUCK" == "yes" ]]; then
    echo "⚠️  STUCK DETECTED: $SESSION — Reason: $STUCK_REASON"
    
    # Save last output to log
    echo "$CURRENT_OUTPUT" > "$LOGS_DIR/${SESSION}-stuck-$(date +%Y%m%d_%H%M%S).log"
    
    # Kill the stuck session
    tmux kill-session -t "$SESSION" 2>/dev/null || true
    
    # Write notification
    echo "⚠️ Agent $SESSION is STUCK and was killed. Reason: $STUCK_REASON. Check logs at $LOGS_DIR/" >> "$NOTIFY_FILE"
    
    echo "   → Killed session and queued notification"
  else
    echo "   ✅ Healthy (output changing, no stuck patterns)"
  fi
done

# Also check for completed agents (notifications waiting)
if [[ -s "$NOTIFY_FILE" ]]; then
  echo "---"
  echo "Pending notifications:"
  cat "$NOTIFY_FILE"
fi


path: scripts/spawn-agent.sh
content: #!/usr/bin/env bash
# Universal spawn-agent.sh — Works with ANY project repo
#
# Usage: spawn-agent.sh <project-dir> <task-id> <description> [agent] [model] [reasoning]
#   project-dir: absolute path to the project root
#   task-id:     unique identifier for this task (used for branch name + tmux session)
#   description: task prompt (or path to a .md prompt file)
#   agent:       claude | codex | gemini  (default: claude)
#   model:       model name override (default: per-agent from duty table)
#   reasoning:   low | medium | high (default: high)

set -euo pipefail

[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" 2>/dev/null || true

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
DUTY_TABLE="$SWARM_DIR/duty-table.json"
USAGE_LOG="$SWARM_DIR/usage-log.json"
TASKS_FILE="$SWARM_DIR/active-tasks.json"

PROJECT_DIR="${1:?Usage: spawn-agent.sh <project-dir> <task-id> <description> [agent] [model] [reasoning]}"
TASK_ID="${2:?Missing task-id}"
DESCRIPTION="${3:?Missing description}"
AGENT="${4:-claude}"
MODEL="${5:-}"
REASONING="${6:-high}"

# Strip empty model arg passed by spawn-batch
[[ -z "$MODEL" ]] && MODEL=""

# ============================================================
# ENDORSEMENT GATE — WB must approve before any work starts
# ============================================================
ENDORSE_FILE="$SWARM_DIR/endorsements/${TASK_ID}.endorsed"
if [[ ! -f "$ENDORSE_FILE" ]]; then
  echo ""
  echo "⛔ ENDORSEMENT REQUIRED"
  echo "   Task '$TASK_ID' has not been endorsed by WB."
  echo ""
  echo "   To endorse, create the file:"
  echo "     mkdir -p $SWARM_DIR/endorsements"
  echo "     echo 'Endorsed by WB at $(date)' > $ENDORSE_FILE"
  echo ""
  echo "   Or use: endorse-task.sh $TASK_ID"
  echo ""
  echo "   This gate exists because the orchestrator must:"
  echo "   1. Read project context (EOR, Obsidian, codebase)"
  echo "   2. Research and pressure-test the approach"
  echo "   3. Present a refined plan to WB"
  echo "   4. Get explicit approval BEFORE spawning agents"
  echo ""
  exit 1
fi
echo "✅ Endorsement verified: $ENDORSE_FILE"

PROJECT_NAME="$(basename "$PROJECT_DIR")"
BRANCH="feat/${TASK_ID}"
WORKTREE_DIR="${PROJECT_DIR}-worktrees/${TASK_ID}"
TMUX_SESSION="${AGENT}-${TASK_ID}"

# Load model from duty table if not specified
if [[ -z "$MODEL" ]] && [[ -f "$DUTY_TABLE" ]] && command -v python3 &>/dev/null; then
  # Map agent to duty role
  DUTY_ROLE=$(python3 -c "
import json
agent_map = {'claude': 'builder', 'codex': 'builder', 'gemini': 'builder'}
print(agent_map.get('$AGENT', 'builder'))
" 2>/dev/null || echo "builder")

  # Use fallback-swap to test + get working model (swaps duty table if primary fails)
  if [[ -x "$SWARM_DIR/fallback-swap.sh" ]]; then
    RESOLVED_CMD=$("$SWARM_DIR/fallback-swap.sh" "$DUTY_ROLE" 2>/dev/null) || true
    if [[ -n "$RESOLVED_CMD" ]]; then
      # Extract model from the resolved command
      MODEL=$(echo "$RESOLVED_CMD" | grep -oP '(?<=--model )\S+|(?<=-m )\S+' || true)
      # Also detect if agent changed (e.g. gemini fallback to claude)
      if echo "$RESOLVED_CMD" | grep -q "^claude"; then
        AGENT="claude"
      elif echo "$RESOLVED_CMD" | grep -q "^gemini"; then
        AGENT="gemini"
      elif echo "$RESOLVED_CMD" | grep -q "^codex"; then
        AGENT="codex"
      fi
    fi
  fi

  # If fallback-swap didn't resolve, try reading duty table directly
  if [[ -z "$MODEL" ]]; then
    MODEL=$(python3 -c "
import json
try:
  with open('$DUTY_TABLE') as f: d = json.load(f)
  table = d.get('dutyTable', {})
  print(table.get('$DUTY_ROLE', {}).get('model', ''))
except Exception:
  pass
" 2>/dev/null)
  fi
fi

# Sanitize: reasoning values are NOT model names
if [[ "$MODEL" == "high" || "$MODEL" == "medium" || "$MODEL" == "low" ]]; then
  REASONING="$MODEL"
  MODEL=""
fi

# Fallback defaults (last resort)
if [[ -z "$MODEL" ]]; then
  case "$AGENT" in
    codex)  MODEL="gpt-5.3-codex" ;;
    claude) MODEL="claude-sonnet-4-6" ;;
    gemini) MODEL="gemini-2.5-pro" ;;
    *)      MODEL="claude-sonnet-4-6" ;;
  esac
fi

# Update tmux session name in case agent changed
TMUX_SESSION="${AGENT}-${TASK_ID}"

echo "🐝 Spawning agent: $AGENT ($MODEL) for task: $TASK_ID"
echo "   Project: $PROJECT_NAME ($PROJECT_DIR)"

# Check if description is a file path
if [[ -f "$DESCRIPTION" ]]; then
  PROMPT=$(cat "$DESCRIPTION")
else
  PROMPT="$DESCRIPTION"
fi

# Work log path for this session
WORKLOG="/tmp/worklog-${TMUX_SESSION}.md"

# Append work log + completion instructions
PROMPT="${PROMPT}

## 📝 WORK LOG — MANDATORY

You MUST maintain a work log throughout your session at: ${WORKLOG}

**At the START of your work**, initialize the log:
\`\`\`bash
cat > ${WORKLOG} << 'EOF'
# Work Log: ${TMUX_SESSION}
## Task: ${TASK_ID} (${PROJECT_NAME})
## Branch: ${BRANCH}
---
EOF
\`\`\`

**As you work**, append entries to the log after each significant step:
\`\`\`bash
cat >> ${WORKLOG} << 'EOF'

### [Step N] <what you did>
- **Files changed:** list of files
- **What:** brief description of the change
- **Why:** reasoning / problem being solved
- **Decisions:** any tradeoffs or choices made
- **Issues found:** anything unexpected
EOF
\`\`\`

**At the END**, append a summary:
\`\`\`bash
cat >> ${WORKLOG} << 'EOF'

## Summary
- **Total files changed:** N
- **Key changes:** bullet list of what was built/fixed
- **Build status:** pass/fail
- **Known issues:** any remaining concerns
- **Integration notes:** what the next agent (reviewer/integrator) should know
EOF
\`\`\`

This work log is READ BY OTHER AGENTS in the pipeline (reviewers, integrators). Write it for them — be specific about what you changed and why. Vague notes like \"fixed stuff\" are useless. Include file paths, function names, and reasoning.

## ✅ WHEN YOU ARE DONE:
1. Finalize your work log with the summary section above
2. Commit all changes with a clear commit message
3. Push the branch: git push origin ${BRANCH}
4. Open a PR if applicable: gh pr create --fill
5. If this is a UI change, include a screenshot in the PR description
6. Exit when complete"

# Set up worktree if project has git
if [[ -d "$PROJECT_DIR/.git" ]]; then
  mkdir -p "$(dirname "$WORKTREE_DIR")"
  cd "$PROJECT_DIR"
  git fetch origin main 2>/dev/null || git fetch origin 2>/dev/null || true
  git worktree add "$WORKTREE_DIR" -b "$BRANCH" origin/main 2>/dev/null || {
    if [[ -d "$WORKTREE_DIR" ]]; then
      echo "⚠️  Worktree exists, reusing..."
    else
      git worktree add "$WORKTREE_DIR" -b "$BRANCH" HEAD 2>/dev/null || {
        echo "⚠️  Using project dir directly (no worktree)"
        WORKTREE_DIR="$PROJECT_DIR"
      }
    fi
  }
  WORK_DIR="$WORKTREE_DIR"
else
  echo "⚠️  No git repo — working in project dir directly"
  WORK_DIR="$PROJECT_DIR"
fi

# Install deps if needed
if [[ -f "$WORK_DIR/package.json" ]]; then
  cd "$WORK_DIR"
  if [[ -f "pnpm-lock.yaml" ]]; then
    pnpm install 2>/dev/null || true
  elif [[ -f "yarn.lock" ]]; then
    yarn install 2>/dev/null || true
  elif [[ -f "package-lock.json" ]]; then
    npm install 2>/dev/null || true
  fi
fi

# Write prompt to a file to avoid shell expansion issues with special chars
PROMPT_FILE="$SWARM_DIR/logs/${TMUX_SESSION}-prompt.md"
printf '%s' "$PROMPT" > "$PROMPT_FILE"

# Write a runner script that reads from the prompt file
NOTIFY_FILE="$SWARM_DIR/pending-notifications.txt"
RUNNER_SCRIPT="$SWARM_DIR/logs/${TMUX_SESSION}-run.sh"
cat > "$RUNNER_SCRIPT" << RUNNER_EOF
#!/bin/bash
cd "$WORK_DIR"
PROMPT=\$(cat "$PROMPT_FILE")
case "$AGENT" in
  codex)
    codex --model $MODEL -c "model_reasoning_effort=$REASONING" --full-auto "\$PROMPT"
    ;;
  claude)
    claude --model $MODEL --permission-mode bypassPermissions --print "\$PROMPT"
    ;;
  gemini)
    GEMINI_API_KEY="${GEMINI_API_KEY:-}" gemini --model $MODEL -p "\$PROMPT"
    ;;
esac
# NOTE: Do NOT write to pending-notifications.txt here.
# notify-on-complete.sh watcher handles all completion notifications
# (including Telegram). Writing here causes duplicate notifications.
RUNNER_EOF
chmod +x "$RUNNER_SCRIPT"

WRAPPED_CMD="bash $RUNNER_SCRIPT"

# Spawn tmux session
tmux new-session -d -s "$TMUX_SESSION" -c "$WORK_DIR" "$WRAPPED_CMD" 2>/dev/null || {
  echo "⚠️  tmux session $TMUX_SESSION already exists"
  exit 1
}

# Enable logging
mkdir -p "$SWARM_DIR/logs"
tmux pipe-pane -t "$TMUX_SESSION" "cat >> $SWARM_DIR/logs/${TMUX_SESSION}.log"

# AUTO-NOTIFICATION: Start background watcher that polls every 60s
# This is the REAL fix — doesn't depend on heartbeat or DZ remembering
nohup "$SWARM_DIR/notify-on-complete.sh" "$TMUX_SESSION" "Agent $TMUX_SESSION completed task: $TASK_ID ($PROJECT_NAME)" --review "$PROJECT_DIR" \
  >> "$SWARM_DIR/logs/${TMUX_SESSION}-watcher.log" 2>&1 &
WATCHER_PID=$!
echo "👁️ Watcher PID: $WATCHER_PID (polls every 60s, auto-notifies on completion)"

echo "✅ Agent running in tmux session: $TMUX_SESSION"
echo "   Work dir: $WORK_DIR"
echo "   Branch: $BRANCH"
echo "   Log: $SWARM_DIR/logs/${TMUX_SESSION}.log"
echo "   Watcher: PID $WATCHER_PID (auto-notify on complete)"

# Register task
TIMESTAMP=$(date +%s)000
mkdir -p "$SWARM_DIR"
[[ -f "$TASKS_FILE" ]] || echo '{"tasks":[]}' > "$TASKS_FILE"

python3 -c "
import json
task = {
  'id': '$TASK_ID',
  'tmuxSession': '$TMUX_SESSION',
  'agent': '$AGENT',
  'model': '$MODEL',
  'description': '''$DESCRIPTION'''[:200],
  'project': '$PROJECT_NAME',
  'projectDir': '$PROJECT_DIR',
  'worktree': '$WORK_DIR',
  'branch': '$BRANCH',
  'startedAt': $TIMESTAMP,
  'status': 'running',
  'retries': 0,
  'notifyOnComplete': True
}
with open('$TASKS_FILE') as f: data = json.load(f)
data['tasks'].append(task)
with open('$TASKS_FILE', 'w') as f: json.dump(data, f, indent=2)
" 2>/dev/null && echo "📋 Task registered" || echo "⚠️  Task registration failed (non-critical)"

# Log usage
[[ -f "$USAGE_LOG" ]] || echo '[]' > "$USAGE_LOG"
python3 -c "
import json
from datetime import datetime
entry = {
  'timestamp': datetime.now().isoformat(),
  'taskId': '$TASK_ID',
  'agent': '$AGENT',
  'model': '$MODEL',
  'project': '$PROJECT_NAME'
}
with open('$USAGE_LOG') as f: data = json.load(f)
data.append(entry)
with open('$USAGE_LOG', 'w') as f: json.dump(data, f, indent=2)
" 2>/dev/null && echo "📊 Usage logged" || true


path: scripts/spawn-batch.sh
content: #!/usr/bin/env bash
# spawn-batch.sh — Spawn up to 10 subteams, then auto-start integration watcher.
#
# Usage:
#   spawn-batch.sh <project-dir> <batch-id> <batch-description> <tasks-json>
#
# tasks-json format:
# [
#   {"id":"task-1","description":"...","agent":"codex","model":"gpt-5.3-codex","reasoning":"high"},
#   {"id":"task-2","description":"..."}
# ]
#
# Notes:
# - Preserves existing swarm logging: per-subteam work logs + ESR logs remain unchanged.
# - Starts integration watcher immediately (it waits until all subteams finish before integrating).

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
SPAWN_AGENT="$SWARM_DIR/spawn-agent.sh"
INTEGRATION_WATCHER="$SWARM_DIR/integration-watcher.sh"
ENDORSE_SCRIPT="$SWARM_DIR/endorse-task.sh"
LOG_DIR="$SWARM_DIR/logs"
mkdir -p "$LOG_DIR"

PROJECT_DIR="${1:?Usage: spawn-batch.sh <project-dir> <batch-id> <batch-description> <tasks-json>}"
BATCH_ID="${2:?Missing batch-id}"
BATCH_DESC="${3:?Missing batch-description}"
TASKS_JSON="${4:?Missing tasks-json path}"

MAX_PARALLEL=10

if [[ ! -d "$PROJECT_DIR" ]]; then
  echo "Error: project dir not found: $PROJECT_DIR" >&2
  exit 1
fi
if [[ ! -f "$TASKS_JSON" ]]; then
  echo "Error: tasks json not found: $TASKS_JSON" >&2
  exit 1
fi
if [[ ! -x "$SPAWN_AGENT" || ! -x "$INTEGRATION_WATCHER" ]]; then
  echo "Error: required scripts missing or not executable in $SWARM_DIR" >&2
  exit 1
fi

mapfile -t TASK_LINES < <(python3 - <<'PY' "$TASKS_JSON"
import json,sys
p=sys.argv[1]
with open(p,'r',encoding='utf-8') as f:
    data=json.load(f)
if not isinstance(data,list):
    raise SystemExit('tasks-json must be a JSON array')
for t in data:
    if not isinstance(t,dict):
        raise SystemExit('each task must be an object')
    tid=t.get('id','').strip()
    desc=t.get('description','').strip()
    if not tid or not desc:
        raise SystemExit('each task requires id + description')
    agent=(t.get('agent') or 'claude').strip()
    model=(t.get('model') or '').strip()
    reasoning=(t.get('reasoning') or 'high').strip()
    print('\t'.join([tid,desc,agent,model,reasoning]))
PY
)

TASK_COUNT=${#TASK_LINES[@]}
if [[ $TASK_COUNT -lt 1 ]]; then
  echo "Error: no tasks found in $TASKS_JSON" >&2
  exit 1
fi
if [[ $TASK_COUNT -gt $MAX_PARALLEL ]]; then
  echo "Error: max parallel is $MAX_PARALLEL (got $TASK_COUNT)" >&2
  exit 1
fi

SESSIONS=()

echo "🐝 Batch $BATCH_ID: spawning $TASK_COUNT subteams"

for line in "${TASK_LINES[@]}"; do
  IFS=$'\t' read -r TASK_ID DESCRIPTION AGENT MODEL REASONING <<< "$line"

  # Keep existing endorsement gate behavior; auto-endorse if missing for orchestrator convenience.
  ENDORSE_FILE="$SWARM_DIR/endorsements/${TASK_ID}.endorsed"
  if [[ ! -f "$ENDORSE_FILE" ]]; then
    "$ENDORSE_SCRIPT" "$TASK_ID" >/dev/null
  fi

  if [[ -n "$MODEL" ]]; then
    "$SPAWN_AGENT" "$PROJECT_DIR" "$TASK_ID" "$DESCRIPTION" "$AGENT" "$MODEL" "$REASONING"
  else
    "$SPAWN_AGENT" "$PROJECT_DIR" "$TASK_ID" "$DESCRIPTION" "$AGENT" "" "$REASONING"
  fi

  SESSIONS+=("${AGENT}-${TASK_ID}")
done

INTEG_LOG="$LOG_DIR/integration-${BATCH_ID}-watcher.log"
nohup "$INTEGRATION_WATCHER" "$PROJECT_DIR" "$BATCH_DESC" "${SESSIONS[@]}" >> "$INTEG_LOG" 2>&1 &
INTEG_PID=$!

echo "🔗 Integration watcher started"
echo "   PID: $INTEG_PID"
echo "   Log: $INTEG_LOG"
echo "   Sessions: ${SESSIONS[*]}"

# Record batch metadata for activity traceability
BATCH_META="$LOG_DIR/batch-${BATCH_ID}.json"
python3 - <<'PY' "$BATCH_META" "$PROJECT_DIR" "$BATCH_ID" "$BATCH_DESC" "$INTEG_PID" "$INTEG_LOG" "${SESSIONS[*]}"
import json,sys,datetime
path,project,bid,desc,pid,ilog,sessions = sys.argv[1:8]
obj={
  'batchId': bid,
  'projectDir': project,
  'description': desc,
  'createdAt': datetime.datetime.now().astimezone().isoformat(),
  'integrationWatcher': {'pid': int(pid), 'log': ilog},
  'sessions': sessions.split() if sessions else []
}
with open(path,'w',encoding='utf-8') as f:
  json.dump(obj,f,indent=2)
  f.write('\n')
PY

echo "🧾 Batch metadata: $BATCH_META"


path: scripts/start-integration.sh
content: #!/usr/bin/env bash
# start-integration.sh — Quick integration watcher kickoff for manual spawns.
#
# Usage:
#   start-integration.sh <project-dir> "description" session1 session2 [session3 ...]
#
# Call this IMMEDIATELY after manually spawning 2+ parallel tmux agents.
# It starts the integration watcher in the background — same as spawn-batch.sh does.
#
# Example:
#   start-integration.sh "~/projects/MyApp" "Feature X (backend + frontend)" mb-backend mb-frontend

set -euo pipefail

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
INTEGRATION_WATCHER="$SWARM_DIR/integration-watcher.sh"
LOG_DIR="$SWARM_DIR/logs"
mkdir -p "$LOG_DIR"

PROJECT_DIR="${1:?Usage: start-integration.sh <project-dir> <description> <session1> [session2] ...}"
DESCRIPTION="${2:?Missing description}"
shift 2
SESSIONS=("$@")

if [[ ${#SESSIONS[@]} -lt 2 ]]; then
  echo "⚠️  Need at least 2 sessions for integration watching (got ${#SESSIONS[@]})"
  echo "   For single agents, notify-on-complete.sh is sufficient."
  exit 1
fi

if [[ ! -x "$INTEGRATION_WATCHER" ]]; then
  echo "Error: integration-watcher.sh not found or not executable at $SWARM_DIR" >&2
  exit 1
fi

# Verify all sessions exist in tmux
ACTIVE=$(tmux ls 2>/dev/null | cut -d: -f1 || echo "")
MISSING=()
for sess in "${SESSIONS[@]}"; do
  if ! echo "$ACTIVE" | grep -qE "^${sess}$"; then
    MISSING+=("$sess")
  fi
done

if [[ ${#MISSING[@]} -gt 0 ]]; then
  echo "⚠️  Warning: these tmux sessions not found (may have already finished): ${MISSING[*]}"
  echo "   Proceeding anyway — integration watcher handles already-completed sessions."
fi

PROJECT_NAME=$(basename "$PROJECT_DIR")
INTEG_LOG="$LOG_DIR/integration-${PROJECT_NAME}-$(date +%Y%m%d-%H%M).log"

nohup "$INTEGRATION_WATCHER" "$PROJECT_DIR" "$DESCRIPTION" "${SESSIONS[@]}" >> "$INTEG_LOG" 2>&1 &
INTEG_PID=$!

echo "🔗 Integration watcher started"
echo "   PID:      $INTEG_PID"
echo "   Sessions: ${SESSIONS[*]}"
echo "   Log:      $INTEG_LOG"
echo ""
echo "   It will wait for all agents to finish, then auto-review cross-dependencies."


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)