Files
bootstrap/tools/orchestrator/_lib.sh
Jason Woltje 5ba531e2d0 feat: r0 coordinator tooling for orchestrator protocol
Implements the manual coordinator workflow for multi-session agent
orchestration. Agents stop after one milestone (confirmed limitation);
these tools let the human coordinator check status, generate continuation
prompts, and chain sessions together.

New:
- tools/orchestrator/ — 5 scripts + shared library (_lib.sh)
  - mission-init.sh: initialize mission with milestones and state files
  - mission-status.sh: dashboard showing milestones, tasks, sessions
  - session-status.sh: check if agent is running/stale/dead
  - continue-prompt.sh: generate paste-ready continuation prompt
  - session-resume.sh: crash recovery with dirty state detection
- guides/ORCHESTRATOR-PROTOCOL.md: agent-facing mission lifecycle guide
- templates/docs/: mission manifest, scratchpad, continuation templates
- templates/repo/.mosaic/orchestrator/mission.json: state file template

Modified:
- bin/mosaic: add 'coord' subcommand + resume advisory on launch
- AGENTS.md: conditional loading for protocol guide + rule 37
- bin/mosaic-doctor: checks for new coordinator files
- session hooks: mission detection on start, cleanup on end

Usage: mosaic coord init|mission|status|continue|resume

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:22:50 -06:00

387 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# _lib.sh — Shared functions for r0 coordinator scripts
#
# Usage: source ~/.config/mosaic/tools/orchestrator/_lib.sh
#
# Provides state file access, TASKS.md parsing, session lock management,
# process health checks, and formatting utilities.
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
ORCH_SUBDIR=".mosaic/orchestrator"
MISSION_FILE="mission.json"
SESSION_LOCK_FILE="session.lock"
MANIFEST_FILE="docs/MISSION-MANIFEST.md"
TASKS_MD="docs/TASKS.md"
SCRATCHPAD_DIR="docs/scratchpads"
# Thresholds (seconds)
STALE_THRESHOLD=300 # 5 minutes
DEAD_THRESHOLD=1800 # 30 minutes
# ─── Color support ───────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
C_GREEN='\033[0;32m'
C_RED='\033[0;31m'
C_YELLOW='\033[0;33m'
C_CYAN='\033[0;36m'
C_BOLD='\033[1m'
C_DIM='\033[2m'
C_RESET='\033[0m'
else
C_GREEN='' C_RED='' C_YELLOW='' C_CYAN='' C_BOLD='' C_DIM='' C_RESET=''
fi
# ─── Dependency checks ──────────────────────────────────────────────────────
_require_jq() {
if ! command -v jq &>/dev/null; then
echo -e "${C_RED}Error: jq is required but not installed${C_RESET}" >&2
return 1
fi
}
# ─── Project / state file access ────────────────────────────────────────────
# Return the orchestrator directory for a project
orch_dir() {
local project="${1:-.}"
echo "$project/$ORCH_SUBDIR"
}
# Return the mission.json path for a project
mission_path() {
local project="${1:-.}"
echo "$(orch_dir "$project")/$MISSION_FILE"
}
# Exit with error if mission.json is missing or inactive
require_mission() {
local project="${1:-.}"
local mp
mp="$(mission_path "$project")"
if [[ ! -f "$mp" ]]; then
echo -e "${C_RED}No mission found at $mp${C_RESET}" >&2
echo "Initialize one with: mosaic coord init --name \"Mission Name\"" >&2
return 1
fi
_require_jq || return 1
local status
status="$(jq -r '.status // "inactive"' "$mp")"
if [[ "$status" == "inactive" ]]; then
echo -e "${C_YELLOW}Mission exists but is inactive. Initialize with: mosaic coord init${C_RESET}" >&2
return 1
fi
}
# Cat mission.json (caller pipes to jq)
load_mission() {
local project="${1:-.}"
cat "$(mission_path "$project")"
}
# ─── Atomic JSON write ──────────────────────────────────────────────────────
write_json() {
local path="$1"
local content="$2"
local tmp
tmp="$(mktemp "${path}.tmp.XXXXXX")"
echo "$content" > "$tmp"
mv "$tmp" "$path"
}
# ─── TASKS.md parsing ───────────────────────────────────────────────────────
# Parse TASKS.md pipe-delimited table and output JSON counts
count_tasks_md() {
local project="${1:-.}"
local tasks_file="$project/$TASKS_MD"
if [[ ! -f "$tasks_file" ]]; then
echo '{"total":0,"done":0,"in_progress":0,"pending":0,"failed":0,"blocked":0}'
return
fi
awk -F'|' '
/^\|.*[Ii][Dd].*[Ss]tatus/ { header=1; next }
header && /^\|.*---/ { data=1; next }
data && /^\|/ {
gsub(/^[ \t]+|[ \t]+$/, "", $3)
status = tolower($3)
total++
if (status == "done" || status == "completed") done++
else if (status == "in-progress" || status == "in_progress") inprog++
else if (status == "not-started" || status == "pending" || status == "todo") pending++
else if (status == "failed") failed++
else if (status == "blocked") blocked++
}
data && !/^\|/ && total > 0 { exit }
END {
printf "{\"total\":%d,\"done\":%d,\"in_progress\":%d,\"pending\":%d,\"failed\":%d,\"blocked\":%d}\n",
total, done, inprog, pending, failed, blocked
}
' "$tasks_file"
}
# Return the ID of the first not-started/pending task
find_next_task() {
local project="${1:-.}"
local tasks_file="$project/$TASKS_MD"
if [[ ! -f "$tasks_file" ]]; then
echo ""
return
fi
awk -F'|' '
/^\|.*[Ii][Dd].*[Ss]tatus/ { header=1; next }
header && /^\|.*---/ { data=1; next }
data && /^\|/ {
gsub(/^[ \t]+|[ \t]+$/, "", $2)
gsub(/^[ \t]+|[ \t]+$/, "", $3)
status = tolower($3)
if (status == "not-started" || status == "pending" || status == "todo") {
print $2
exit
}
}
' "$tasks_file"
}
# ─── Session lock management ────────────────────────────────────────────────
session_lock_path() {
local project="${1:-.}"
echo "$(orch_dir "$project")/$SESSION_LOCK_FILE"
}
session_lock_read() {
local project="${1:-.}"
local lp
lp="$(session_lock_path "$project")"
if [[ -f "$lp" ]]; then
cat "$lp"
return 0
fi
return 1
}
session_lock_write() {
local project="${1:-.}"
local session_id="$2"
local runtime="$3"
local pid="$4"
local milestone_id="${5:-}"
local lp
lp="$(session_lock_path "$project")"
_require_jq || return 1
local json
json=$(jq -n \
--arg sid "$session_id" \
--arg rt "$runtime" \
--arg pid "$pid" \
--arg ts "$(iso_now)" \
--arg pp "$(cd "$project" && pwd)" \
--arg mid "$milestone_id" \
'{
session_id: $sid,
runtime: $rt,
pid: ($pid | tonumber),
started_at: $ts,
project_path: $pp,
milestone_id: $mid
}')
write_json "$lp" "$json"
}
session_lock_clear() {
local project="${1:-.}"
local lp
lp="$(session_lock_path "$project")"
rm -f "$lp"
}
# ─── Process health checks ──────────────────────────────────────────────────
is_pid_alive() {
local pid="$1"
kill -0 "$pid" 2>/dev/null
}
detect_agent_runtime() {
local pid="$1"
local cmdline
if [[ -f "/proc/$pid/cmdline" ]]; then
cmdline="$(tr '\0' ' ' < "/proc/$pid/cmdline")"
if [[ "$cmdline" == *claude* ]]; then
echo "claude"
elif [[ "$cmdline" == *codex* ]]; then
echo "codex"
elif [[ "$cmdline" == *opencode* ]]; then
echo "opencode"
else
echo "unknown"
fi
else
echo "unknown"
fi
}
# ─── Time / formatting utilities ────────────────────────────────────────────
iso_now() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
epoch_now() {
date +%s
}
# Convert ISO timestamp to epoch seconds
iso_to_epoch() {
local ts="$1"
date -d "$ts" +%s 2>/dev/null || echo 0
}
# Return most recent modification time (epoch) of key project files
last_activity_time() {
local project="${1:-.}"
local latest=0
local ts
for f in \
"$project/$TASKS_MD" \
"$project/$(orch_dir "$project")/$MISSION_FILE" \
"$(orch_dir "$project")/state.json"; do
if [[ -f "$f" ]]; then
ts="$(stat -c %Y "$f" 2>/dev/null || echo 0)"
(( ts > latest )) && latest=$ts
fi
done
# Also check git log for last commit time
if git -C "$project" rev-parse --is-inside-work-tree &>/dev/null; then
ts="$(git -C "$project" log -1 --format=%ct 2>/dev/null || echo 0)"
(( ts > latest )) && latest=$ts
fi
echo "$latest"
}
# Format seconds-ago into human-readable string
format_ago() {
local epoch="$1"
local now
now="$(epoch_now)"
local diff=$(( now - epoch ))
if (( diff < 60 )); then
echo "${diff}s ago"
elif (( diff < 3600 )); then
echo "$(( diff / 60 ))m ago"
elif (( diff < 86400 )); then
echo "$(( diff / 3600 ))h $(( (diff % 3600) / 60 ))m ago"
else
echo "$(( diff / 86400 ))d ago"
fi
}
# Format seconds into duration string
format_duration() {
local secs="$1"
if (( secs < 60 )); then
echo "${secs}s"
elif (( secs < 3600 )); then
echo "$(( secs / 60 ))m $(( secs % 60 ))s"
else
echo "$(( secs / 3600 ))h $(( (secs % 3600) / 60 ))m"
fi
}
# ─── Session ID generation ──────────────────────────────────────────────────
next_session_id() {
local project="${1:-.}"
local mp
mp="$(mission_path "$project")"
if [[ ! -f "$mp" ]]; then
echo "sess-001"
return
fi
_require_jq || { echo "sess-001"; return; }
local count
count="$(jq '.sessions | length' "$mp")"
printf "sess-%03d" "$(( count + 1 ))"
}
# ─── Milestone helpers ───────────────────────────────────────────────────────
# Get current milestone (first in-progress, or first pending)
current_milestone_id() {
local project="${1:-.}"
_require_jq || return 1
local mp
mp="$(mission_path "$project")"
[[ -f "$mp" ]] || return 1
local mid
mid="$(jq -r '[.milestones[] | select(.status == "in-progress")][0].id // empty' "$mp")"
if [[ -z "$mid" ]]; then
mid="$(jq -r '[.milestones[] | select(.status == "pending")][0].id // empty' "$mp")"
fi
echo "$mid"
}
# Get milestone name by ID
milestone_name() {
local project="${1:-.}"
local mid="$2"
_require_jq || return 1
local mp
mp="$(mission_path "$project")"
[[ -f "$mp" ]] || return 1
jq -r --arg id "$mid" '.milestones[] | select(.id == $id) | .name // empty' "$mp"
}
# Get next milestone after the given one
next_milestone_id() {
local project="${1:-.}"
local current_id="$2"
_require_jq || return 1
local mp
mp="$(mission_path "$project")"
[[ -f "$mp" ]] || return 1
jq -r --arg cid "$current_id" '
.milestones as $ms |
($ms | to_entries | map(select(.value.id == $cid)) | .[0].key // -1) as $idx |
if $idx >= 0 and ($idx + 1) < ($ms | length) then
$ms[$idx + 1].id
else
empty
end
' "$mp"
}
# ─── Slugify ─────────────────────────────────────────────────────────────────
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//'
}