Research
Hook
Hooks
What research-council runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add hamelsmu/research-council > /plugin install research-council@research-council
Ships with research-council. Installing the plugin gets these hooks.
In the plugin's words
How research-council describes its own hook set.
Deep Research Council: orchestrates parallel research across Claude and Codex
Where it lives
- hooks/orchestrator-stop-hook.shRunsGitHub
Read the script
#!/usr/bin/env bash # Deep Research Council — Orchestrator Stop Hook # # Phase state machine: # research → run 2 parallel research agents → refinement # refinement → run 2 parallel refinement agents → synthesis # synthesis → check for final report → allow exit # # Fail-open: on any unexpected error, allow exit (never trap the user). LOG_FILE=".claude/deep-research.log" log() { mkdir -p "$(dirname "$LOG_FILE")" echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] orchestrator: $*" >> "$LOG_FILE" } # Portable sed -i (macOS vs GNU) sedi() { if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' "$@"; else sed -i "$@"; fi; } # Escape a string for safe use in sed replacement patterns. # Must escape \ first (to avoid double-escaping), then & and /. sed_escape() { printf '%s\n' "$1" | sed -e 's/[\\]/\\&/g' -e 's/[&/]/\\&/g'; } # Guard: jq is required by this hook command -v jq &>/dev/null || { log "ERROR: jq not found in PATH"; exit 0; } # On any error, allow exit trap 'log "ERROR: hook exited via ERR trap (line $LINENO)"; rm -f ".claude/deep-research.lock"; printf "{\"decision\":\"approve\"}\n"; exit 0' ERR # Consume stdin HOOK_INPUT=$(cat) STATE_FILE=".claude/deep-research.local.md" LOCK_FILE=".claude/deep-research.lock" # No active session → allow exit if [ ! -f "$STATE_FILE" ]; then exit 0 fi # ── Parse state file fields ────────────────────────────────────────────── parse_field() { sed -n "s/^${1}: *//p" "$STATE_FILE" | head -1 } ACTIVE=$(parse_field "active") PHASE=$(parse_field "phase") RESEARCH_ID=$(parse_field "research_id") TEST_MODE=$(parse_field "test_mode") MAX_ITERS=$(parse_field "max_iterations") CLAUDE_MODEL=$(parse_field "claude_model") CODEX_MODEL=$(parse_field "codex_model") CODEX_REASONING=$(parse_field "codex_reasoning") # Not active → clean up if [ "$ACTIVE" != "true" ]; then rm -f "$STATE_FILE" exit 0 fi # Validate research_id format if ! echo "$RESEARCH_ID" | grep -qE '^[0-9]{8}-[0-9]{6}-[0-9a-f]{6}$'; then log "ERROR: invalid research_id format: $RESEARCH_ID" rm -f "$STATE_FILE" exit 0 fi # ── Lock helpers (defined early — used by session-ID check and phase execution) ── # Lock file stores "PID:EPOCH" to defend against PID-reuse false positives. # A lock older than 2 hours is treated as stale regardless of PID liveness. LOCK_MAX_AGE=7200 # 2 hours in seconds is_lock_alive() { [ -f "$LOCK_FILE" ] || return 1 local lock_content pid lock_epoch now age lock_content=$(cat "$LOCK_FILE" 2>/dev/null || echo "") pid="${lock_content%%:*}" lock_epoch="${lock_content#*:}" [ -z "$pid" ] && return 1 # If lock has no epoch (legacy format), treat as stale [ "$lock_epoch" = "$pid" ] && return 1 # Validate numeric format to avoid arithmetic errors on malformed content [[ "$pid" =~ ^[0-9]+$ ]] || return 1 [[ "$lock_epoch" =~ ^[0-9]+$ ]] || return 1 # Check age — stale locks are dead regardless of PID now=$(date +%s) age=$(( now - lock_epoch )) if [ "$age" -gt "$LOCK_MAX_AGE" ]; then log "WARN: lock is ${age}s old (max ${LOCK_MAX_AGE}s), treating as stale despite PID=$pid" return 1 fi # PID must still be alive kill -0 "$pid" 2>/dev/null || return 1 return 0 } # ── Session ID check: skip if different session ────────────────────────── CURRENT_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "") RECORDED_SESSION=$(parse_field "session_id") # First run: stamp the session ID into the state file if [ -z "$RECORDED_SESSION" ] && [ -n "$CURRENT_SESSION" ]; then sedi "s/^session_id:$/session_id: $(sed_escape "$CURRENT_SESSION")/" "$STATE_FILE" RECORDED_SESSION="$CURRENT_SESSION" log "Stamped session_id: ${CURRENT_SESSION}" fi # Different session — check if we can adopt the orphaned research if [ -n "$RECORDED_SESSION" ] && [ -n "$CURRENT_SESSION" ] && [ "$CURRENT_SESSION" != "$RECORDED_SESSION" ]; then # If a lock is held by a live, recent process, another session is actively running phases if is_lock_alive; then log "Skipping: different session and lock is active (current=$CURRENT_SESSION, recorded=$RECORDED_SESSION)" exit 0 fi # No active lock — adopt the research into this session log "Adopting orphaned research into new session (old=$RECORDED_SESSION, new=$CURRENT_SESSION)" sedi "s/^session_id: .*$/session_id: $(sed_escape "$CURRENT_SESSION")/" "$STATE_FILE" RECORDED_SESSION="$CURRENT_SESSION" fi # ── Staleness check: auto-clean after 5 hours ──────────────────────────── STARTED_AT=$(parse_field "started_at") if [ -n "$STARTED_AT" ]; then if [[ "$OSTYPE" == "darwin"* ]]; then STARTED_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || echo 0) else STARTED_EPOCH=$(date -d "$STARTED_AT" +%s 2>/dev/null || echo 0) fi NOW_EPOCH=$(date +%s) AGE=$(( NOW_EPOCH - STARTED_EPOCH )) if [ "$AGE" -gt 18000 ]; then # 5 hours = 18000 seconds log "WARN: stale state file (${AGE}s old, started_at=$STARTED_AT), cleaning up" rm -f "$STATE_FILE" exit 0 fi fi # Extract topic (everything after the second --- line in YAML front matter). # Only the first two --- lines are treated as delimiters; any --- in the # topic body (e.g. user researching YAML syntax) is passed through. TOPIC=$(awk '/^---$/ && count<2 {count++; next} count>=2' "$STATE_FILE" | sed '/^$/d') if [ -z "$TOPIC" ]; then log "ERROR: no topic found in state file" rm -f "$STATE_FILE" exit 0 fi WORKSPACE="research/${RESEARCH_ID}" SCRIPT_DIR="$(cd "$(dirname "$0")/../scripts" && pwd)" PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" # ── Lock acquire/release (is_lock_alive defined above) ──────────────── acquire_lock() { if [ -f "$LOCK_FILE" ]; then if is_lock_alive; then local lock_content lock_content=$(cat "$LOCK_FILE" 2>/dev/null || echo "") log "SKIP: another hook is running phases (lock=$lock_content)" jq -n '{decision:"block", reason:"Research agents are still running. Please wait."}' exit 0 else log "WARN: removi
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withresearch-council
A Claude Code plugin that orchestrates deep research across two coding agents — Claude Code and Codex CLI (OpenAI) — running in parallel with cross-pollination refinement and final synthesis.
Get the whole plugin
Stats
107
Stars
15
Forks
Quiet
Maintenance
Shell
Language
8mo ago
Last commit
8mo ago
Created
14h ago
Added

