The compass (identity, moral core) now lives outside the repo at a path specified by EXEC_COMPASS in .env or .env.enc. The agent hard-fails if the compass file is missing — it refuses to start without its soul. This means the factory (dev agent, gardener, planner) can evolve the exec's voice and relationships via PRs to CHARACTER.md, but cannot touch the compass. Only the executive controls it directly. - exec-session.sh: loads compass from $EXEC_COMPASS, merges with CHARACTER.md - exec-briefing.sh: same compass loading, hard fail without it - CHARACTER.md: compass sections replaced with runtime-load comments - COMPASS.md.example: template for the compass file - .env.example: added EXEC_COMPASS variable - exec/AGENTS.md: documented compass separation and EXEC_COMPASS requirement
216 lines
8.9 KiB
Bash
Executable file
216 lines
8.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# exec-session.sh — Spawn or reattach the executive assistant Claude session
|
|
#
|
|
# Unlike cron-driven agents, the exec session is on-demand:
|
|
# 1. Matrix listener receives a message tagged [exec]
|
|
# 2. If no tmux session exists → this script spawns one
|
|
# 3. Message is injected into the session
|
|
# 4. Claude's response is captured and posted back to Matrix
|
|
#
|
|
# Can also be invoked directly for interactive use:
|
|
# exec-session.sh [projects/disinto.toml]
|
|
#
|
|
# The session stays alive for EXEC_SESSION_TTL (default: 1h) of idle time.
|
|
# On exit, Claude updates MEMORY.md and the session is logged to journal.
|
|
# =============================================================================
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
|
|
# Accept project config from argument; default to disinto
|
|
export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
|
|
# shellcheck source=../lib/env.sh
|
|
source "$FACTORY_ROOT/lib/env.sh"
|
|
# shellcheck source=../lib/agent-session.sh
|
|
source "$FACTORY_ROOT/lib/agent-session.sh"
|
|
|
|
LOG_FILE="$SCRIPT_DIR/exec.log"
|
|
SESSION_NAME="exec-${PROJECT_NAME}"
|
|
PHASE_FILE="/tmp/exec-session-${PROJECT_NAME}.phase"
|
|
EXEC_SESSION_TTL="${EXEC_SESSION_TTL:-3600}"
|
|
|
|
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
|
|
|
# ── Check if session already exists ──────────────────────────────────────
|
|
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
|
log "session already active: ${SESSION_NAME}"
|
|
echo "ACTIVE"
|
|
exit 0
|
|
fi
|
|
|
|
# ── Memory check (skip if low) ──────────────────────────────────────────
|
|
AVAIL_MB=$(free -m 2>/dev/null | awk '/Mem:/{print $7}' || echo 9999)
|
|
if [ "${AVAIL_MB:-0}" -lt 2000 ]; then
|
|
log "skipping — only ${AVAIL_MB}MB available (need 2000)"
|
|
exit 1
|
|
fi
|
|
|
|
log "--- Exec session start ---"
|
|
|
|
# ── Load compass (required — lives outside the repo) ──────────────────
|
|
# The compass is the agent's core identity. It cannot live in code because
|
|
# code can be changed by the factory. The compass cannot.
|
|
COMPASS_FILE="${EXEC_COMPASS:-}"
|
|
if [ -z "$COMPASS_FILE" ] || [ ! -f "$COMPASS_FILE" ]; then
|
|
log "FATAL: EXEC_COMPASS not set or file not found (${COMPASS_FILE:-unset})"
|
|
log "The exec agent refuses to start without its compass."
|
|
log "Set EXEC_COMPASS=/path/to/compass.md in .env or .env.enc"
|
|
matrix_send "exec" "❌ Exec agent cannot start: compass file missing (EXEC_COMPASS not configured)" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
COMPASS_BLOCK=$(cat "$COMPASS_FILE")
|
|
log "compass loaded from ${COMPASS_FILE}"
|
|
|
|
# ── Load character (voice, relationships — lives in the repo) ─────────
|
|
CHARACTER_FILE="${EXEC_CHARACTER:-$SCRIPT_DIR/CHARACTER.md}"
|
|
CHARACTER_BLOCK=""
|
|
if [ -f "$CHARACTER_FILE" ]; then
|
|
CHARACTER_BLOCK=$(cat "$CHARACTER_FILE")
|
|
else
|
|
log "WARNING: CHARACTER.md not found at ${CHARACTER_FILE}"
|
|
CHARACTER_BLOCK="(no character file found — use your best judgment)"
|
|
fi
|
|
|
|
# Merge: compass first (identity), then character (voice/relationships)
|
|
CHARACTER_BLOCK="${COMPASS_BLOCK}
|
|
|
|
${CHARACTER_BLOCK}"
|
|
|
|
# ── Load factory context ────────────────────────────────────────────────
|
|
CONTEXT_BLOCK=""
|
|
for ctx in VISION.md AGENTS.md RESOURCES.md; do
|
|
ctx_path="${PROJECT_REPO_ROOT}/${ctx}"
|
|
if [ -f "$ctx_path" ]; then
|
|
CONTEXT_BLOCK="${CONTEXT_BLOCK}
|
|
### ${ctx}
|
|
$(cat "$ctx_path")
|
|
"
|
|
fi
|
|
done
|
|
|
|
# ── Load exec memory ───────────────────────────────────────────────────
|
|
MEMORY_BLOCK="(no previous memory — this is the first conversation)"
|
|
MEMORY_FILE="$PROJECT_REPO_ROOT/exec/MEMORY.md"
|
|
if [ -f "$MEMORY_FILE" ]; then
|
|
MEMORY_BLOCK=$(cat "$MEMORY_FILE")
|
|
fi
|
|
|
|
# ── Load recent journal entries ─────────────────────────────────────────
|
|
JOURNAL_BLOCK=""
|
|
JOURNAL_DIR="$PROJECT_REPO_ROOT/exec/journal"
|
|
if [ -d "$JOURNAL_DIR" ]; then
|
|
JOURNAL_FILES=$(find "$JOURNAL_DIR" -name '*.md' -type f | sort -r | head -3)
|
|
if [ -n "$JOURNAL_FILES" ]; then
|
|
JOURNAL_BLOCK="
|
|
### Recent conversation logs (exec/journal/)
|
|
"
|
|
while IFS= read -r jf; do
|
|
JOURNAL_BLOCK="${JOURNAL_BLOCK}
|
|
#### $(basename "$jf")
|
|
$(head -100 "$jf")
|
|
"
|
|
done <<< "$JOURNAL_FILES"
|
|
fi
|
|
fi
|
|
|
|
# ── Load recent agent activity summary ──────────────────────────────────
|
|
ACTIVITY_BLOCK=""
|
|
# Last planner journal
|
|
PLANNER_LATEST=$(find "$PROJECT_REPO_ROOT/planner/journal" -name '*.md' -type f 2>/dev/null | sort -r | head -1)
|
|
if [ -n "$PLANNER_LATEST" ]; then
|
|
ACTIVITY_BLOCK="${ACTIVITY_BLOCK}
|
|
### Latest planner run ($(basename "$PLANNER_LATEST"))
|
|
$(tail -60 "$PLANNER_LATEST")
|
|
"
|
|
fi
|
|
# Last supervisor journal
|
|
SUPERVISOR_LATEST=$(find "$PROJECT_REPO_ROOT/supervisor/journal" -name '*.md' -type f 2>/dev/null | sort -r | head -1)
|
|
if [ -n "$SUPERVISOR_LATEST" ]; then
|
|
ACTIVITY_BLOCK="${ACTIVITY_BLOCK}
|
|
### Latest supervisor run ($(basename "$SUPERVISOR_LATEST"))
|
|
$(tail -40 "$SUPERVISOR_LATEST")
|
|
"
|
|
fi
|
|
|
|
# Merge activity into journal block
|
|
if [ -n "$ACTIVITY_BLOCK" ]; then
|
|
JOURNAL_BLOCK="${JOURNAL_BLOCK}${ACTIVITY_BLOCK}"
|
|
fi
|
|
|
|
# ── Build prompt ────────────────────────────────────────────────────────
|
|
# Read prompt template and expand variables
|
|
PROMPT="You are the executive assistant for ${FORGE_REPO}. Read your character definition carefully — it is who you are.
|
|
|
|
## Your character
|
|
${CHARACTER_BLOCK}
|
|
|
|
## Factory context
|
|
${CONTEXT_BLOCK}
|
|
|
|
## Your persistent memory
|
|
${MEMORY_BLOCK}
|
|
|
|
## Recent activity
|
|
${JOURNAL_BLOCK}
|
|
|
|
## Forge API reference
|
|
Base URL: ${FORGE_API}
|
|
Auth header: -H \"Authorization: token \${FORGE_TOKEN}\"
|
|
Read issue: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" '${FORGE_API}/issues/{number}' | jq '.body'
|
|
Create issue: curl -sf -X POST -H \"Authorization: token \${FORGE_TOKEN}\" -H 'Content-Type: application/json' '${FORGE_API}/issues' -d '{\"title\":\"...\",\"body\":\"...\",\"labels\":[LABEL_ID]}'
|
|
List labels: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" '${FORGE_API}/labels'
|
|
Comment: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X POST -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}/comments' -d '{\"body\":\"...\"}'
|
|
Close: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X PATCH -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}' -d '{\"state\":\"closed\"}'
|
|
NEVER echo or include the actual token value in output — always reference \${FORGE_TOKEN}.
|
|
|
|
## Structural analysis (on demand)
|
|
When the conversation calls for it — project health, bottlenecks, what to focus on:
|
|
# Fresh graph: python3 ${FACTORY_ROOT}/lib/build-graph.py --project-root ${PROJECT_REPO_ROOT} --output /tmp/${PROJECT_NAME}-graph-report.json
|
|
# Cached daily: cat /tmp/${PROJECT_NAME}-graph-report.json
|
|
The report contains orphans, cycles, thin_objectives, bottlenecks (betweenness centrality).
|
|
Reach for it when structural reasoning is what the question needs, not by default.
|
|
|
|
## Environment
|
|
FACTORY_ROOT=${FACTORY_ROOT}
|
|
PROJECT_REPO_ROOT=${PROJECT_REPO_ROOT}
|
|
PRIMARY_BRANCH=${PRIMARY_BRANCH}
|
|
PHASE_FILE=${PHASE_FILE}
|
|
|
|
## Response format
|
|
When responding to the executive, write your response between these markers:
|
|
\`\`\`
|
|
---EXEC-RESPONSE-START---
|
|
Your response here.
|
|
---EXEC-RESPONSE-END---
|
|
\`\`\`
|
|
This allows the output capture to extract and post your response to Matrix.
|
|
|
|
## Phase protocol
|
|
When the executive ends the conversation (says goodbye, done, etc.):
|
|
1. Update your memory: write to ${PROJECT_REPO_ROOT}/exec/MEMORY.md
|
|
2. Log the conversation: append to ${PROJECT_REPO_ROOT}/exec/journal/\$(date -u +%Y-%m-%d).md
|
|
3. Signal done: echo 'PHASE:done' > '${PHASE_FILE}'
|
|
On unrecoverable error:
|
|
printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'
|
|
|
|
You are now live. Wait for the executive's first message."
|
|
|
|
# ── Create tmux session ─────────────────────────────────────────────────
|
|
rm -f "$PHASE_FILE"
|
|
|
|
log "Creating tmux session: ${SESSION_NAME}"
|
|
if ! create_agent_session "$SESSION_NAME" "$PROJECT_REPO_ROOT" "$PHASE_FILE"; then
|
|
log "ERROR: failed to create tmux session ${SESSION_NAME}"
|
|
exit 1
|
|
fi
|
|
|
|
# Inject prompt
|
|
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
|
|
log "Prompt injected, session live"
|
|
|
|
# Notify via Matrix
|
|
matrix_send "exec" "Executive assistant session started for ${FORGE_REPO}. Ready for messages." 2>/dev/null || true
|
|
|
|
echo "STARTED"
|