- coord/prdy subcommands now accept --claude/--codex runtime flags - New `mosaic coord run` generates continuation context and launches selected runtime, replacing manual copy/paste workflow - Next-task capsule (.mosaic/orchestrator/next-task.json) provides machine-readable execution context for deterministic session launches - Codex strict orchestrator profile added to runtime/codex/RUNTIME.md - Orchestrator protocol updated with between-session run flow - New smoke-test.sh for orchestration behavior verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
524 lines
14 KiB
Bash
Executable File
524 lines
14 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"
|
|
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 <<EOF
|
|
Now initiating Orchestrator mode...
|
|
|
|
STRICT EXECUTION PROFILE FOR CODEX (HARD GATE)
|
|
- Do NOT ask clarifying questions before your first tool actions unless a Mosaic escalation trigger is hit.
|
|
- Your first actions must be reading mission state files in order.
|
|
- Treat the next-task capsule as authoritative execution input.
|
|
|
|
REQUIRED FIRST ACTIONS (IN ORDER)
|
|
1. Read ~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md
|
|
2. Read docs/MISSION-MANIFEST.md
|
|
3. Read docs/scratchpads/${mission_id}.md
|
|
4. Read docs/TASKS.md
|
|
5. Begin execution on next task: ${next_task}
|
|
|
|
WORKING CONTEXT
|
|
- Project: ${project_path}
|
|
- Quality gates: ${quality_gates}
|
|
- Capsule file: .mosaic/orchestrator/next-task.json
|
|
|
|
Task capsule (JSON):
|
|
\`\`\`json
|
|
${capsule}
|
|
\`\`\`
|
|
|
|
Continuation prompt:
|
|
${continuation_prompt}
|
|
EOF
|
|
}
|
|
|
|
# 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/-$//'
|
|
}
|