#!/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" NEXT_TASK_FILE="next-task.json" 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 } coord_runtime() { local runtime="${MOSAIC_COORD_RUNTIME:-claude}" case "$runtime" in claude|codex) echo "$runtime" ;; *) echo "claude" ;; esac } coord_launch_command() { local runtime runtime="$(coord_runtime)" echo "mosaic $runtime" } coord_run_command() { local runtime runtime="$(coord_runtime)" if [[ "$runtime" == "claude" ]]; then echo "mosaic coord run" else echo "mosaic coord run --$runtime" 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" } next_task_capsule_path() { local project="${1:-.}" echo "$(orch_dir "$project")/$NEXT_TASK_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" } # ─── Next-task capsule helpers ─────────────────────────────────────────────── write_next_task_capsule() { local project="${1:-.}" local runtime="${2:-claude}" local mission_id="${3:-}" local mission_name="${4:-}" local project_path="${5:-}" local quality_gates="${6:-}" local current_ms_id="${7:-}" local current_ms_name="${8:-}" local next_task="${9:-}" local tasks_done="${10:-0}" local tasks_total="${11:-0}" local pct="${12:-0}" local current_branch="${13:-}" _require_jq || return 1 mkdir -p "$(orch_dir "$project")" local payload payload="$(jq -n \ --arg generated_at "$(iso_now)" \ --arg runtime "$runtime" \ --arg mission_id "$mission_id" \ --arg mission_name "$mission_name" \ --arg project_path "$project_path" \ --arg quality_gates "$quality_gates" \ --arg current_ms_id "$current_ms_id" \ --arg current_ms_name "$current_ms_name" \ --arg next_task "$next_task" \ --arg current_branch "$current_branch" \ --arg tasks_done "$tasks_done" \ --arg tasks_total "$tasks_total" \ --arg pct "$pct" \ '{ generated_at: $generated_at, runtime: $runtime, mission_id: $mission_id, mission_name: $mission_name, project_path: $project_path, quality_gates: $quality_gates, current_milestone: { id: $current_ms_id, name: $current_ms_name }, next_task: $next_task, progress: { tasks_done: ($tasks_done | tonumber), tasks_total: ($tasks_total | tonumber), pct: ($pct | tonumber) }, current_branch: $current_branch }')" write_json "$(next_task_capsule_path "$project")" "$payload" } build_codex_strict_kickoff() { local project="${1:-.}" local continuation_prompt="${2:-}" _require_jq || return 1 local capsule_path capsule_path="$(next_task_capsule_path "$project")" local capsule='{}' if [[ -f "$capsule_path" ]]; then capsule="$(cat "$capsule_path")" fi local mission_id next_task project_path quality_gates mission_id="$(echo "$capsule" | jq -r '.mission_id // "unknown"')" next_task="$(echo "$capsule" | jq -r '.next_task // "none"')" project_path="$(echo "$capsule" | jq -r '.project_path // "."')" quality_gates="$(echo "$capsule" | jq -r '.quality_gates // "none"')" cat <= 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/-$//' }