disinto/exec/exec-session.sh
disinto-exec 5c1c91bae2 refactor: extract compass from CHARACTER.md into runtime-loaded secret
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
2026-03-25 15:34:55 +00:00

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"