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>
This commit is contained in:
2026-02-22 17:22:50 -06:00
parent a8e580e1a3
commit 5ba531e2d0
16 changed files with 1944 additions and 0 deletions

386
tools/orchestrator/_lib.sh Executable file
View File

@@ -0,0 +1,386 @@
#!/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/-$//'
}

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
#
# continue-prompt.sh — Generate continuation prompt for next orchestrator session
#
# Usage:
# continue-prompt.sh [--project <path>] [--milestone <id>] [--copy]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"
# ─── Parse arguments ─────────────────────────────────────────────────────────
PROJECT="."
MILESTONE=""
COPY=false
while [[ $# -gt 0 ]]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--milestone) MILESTONE="$2"; shift 2 ;;
--copy) COPY=true; shift ;;
-h|--help)
echo "Usage: continue-prompt.sh [--project <path>] [--milestone <id>] [--copy]"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
_require_jq
require_mission "$PROJECT"
# ─── Load mission data ──────────────────────────────────────────────────────
mission="$(load_mission "$PROJECT")"
mission_name="$(echo "$mission" | jq -r '.name')"
mission_id="$(echo "$mission" | jq -r '.mission_id')"
quality_gates="$(echo "$mission" | jq -r '.quality_gates // "—"')"
project_path="$(echo "$mission" | jq -r '.project_path')"
# Determine current milestone
if [[ -n "$MILESTONE" ]]; then
current_ms_id="$MILESTONE"
else
current_ms_id="$(current_milestone_id "$PROJECT")"
fi
current_ms_name=""
if [[ -n "$current_ms_id" ]]; then
current_ms_name="$(milestone_name "$PROJECT" "$current_ms_id")"
fi
# Task counts
task_counts="$(count_tasks_md "$PROJECT")"
tasks_total="$(echo "$task_counts" | jq '.total')"
tasks_done="$(echo "$task_counts" | jq '.done')"
pct=0
(( tasks_total > 0 )) && pct=$(( (tasks_done * 100) / tasks_total ))
# Next task
next_task="$(find_next_task "$PROJECT")"
# Current branch
current_branch=""
if git -C "$PROJECT" rev-parse --is-inside-work-tree &>/dev/null; then
current_branch="$(git -C "$PROJECT" branch --show-current 2>/dev/null || echo "—")"
fi
# Previous session info
session_count="$(echo "$mission" | jq '.sessions | length')"
prev_session_id="—"
prev_runtime="—"
prev_duration="—"
prev_ended_reason="—"
prev_last_task="—"
if (( session_count > 0 )); then
last_idx=$(( session_count - 1 ))
prev_session_id="$(echo "$mission" | jq -r ".sessions[$last_idx].session_id // \"—\"")"
prev_runtime="$(echo "$mission" | jq -r ".sessions[$last_idx].runtime // \"—\"")"
prev_ended_reason="$(echo "$mission" | jq -r ".sessions[$last_idx].ended_reason // \"—\"")"
prev_last_task="$(echo "$mission" | jq -r ".sessions[$last_idx].last_task_id // \"—\"")"
s_start="$(echo "$mission" | jq -r ".sessions[$last_idx].started_at // \"\"")"
s_end="$(echo "$mission" | jq -r ".sessions[$last_idx].ended_at // \"\"")"
if [[ -n "$s_start" && -n "$s_end" && "$s_end" != "" ]]; then
s_epoch="$(iso_to_epoch "$s_start")"
e_epoch="$(iso_to_epoch "$s_end")"
if (( e_epoch > 0 && s_epoch > 0 )); then
prev_duration="$(format_duration $(( e_epoch - s_epoch )))"
fi
fi
fi
# ─── Generate prompt ────────────────────────────────────────────────────────
prompt="$(cat <<EOF
## Continuation Mission
Continue **$mission_name** from existing state.
## Setup
- **Project:** $project_path
- **State:** docs/TASKS.md (already populated — ${tasks_done}/${tasks_total} tasks complete)
- **Manifest:** docs/MISSION-MANIFEST.md
- **Scratchpad:** docs/scratchpads/${mission_id}.md
- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md
- **Quality gates:** $quality_gates
## Resume Point
- **Current milestone:** ${current_ms_name:-—} (${current_ms_id:-—})
- **Next task:** ${next_task:-—}
- **Progress:** ${tasks_done}/${tasks_total} tasks (${pct}%)
- **Branch:** ${current_branch:-—}
## Previous Session Context
- **Session:** $prev_session_id ($prev_runtime, $prev_duration)
- **Ended:** $prev_ended_reason
- **Last completed task:** $prev_last_task
## Instructions
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR.md\` for full protocol
2. Read \`docs/MISSION-MANIFEST.md\` for mission scope and status
3. Read \`docs/scratchpads/${mission_id}.md\` for session history and decisions
4. Read \`docs/TASKS.md\` for current task state
5. \`git pull --rebase\` to sync latest changes
6. Continue execution from task **${next_task:-next-pending}**
7. Follow Two-Phase Completion Protocol
8. You are the SOLE writer of \`docs/TASKS.md\`
EOF
)"
# ─── Output ──────────────────────────────────────────────────────────────────
if [[ "$COPY" == true ]]; then
if command -v wl-copy &>/dev/null; then
echo "$prompt" | wl-copy
echo -e "${C_GREEN}Continuation prompt copied to clipboard (wl-copy)${C_RESET}" >&2
elif command -v xclip &>/dev/null; then
echo "$prompt" | xclip -selection clipboard
echo -e "${C_GREEN}Continuation prompt copied to clipboard (xclip)${C_RESET}" >&2
else
echo -e "${C_YELLOW}No clipboard tool found (wl-copy or xclip). Printing to stdout.${C_RESET}" >&2
echo "$prompt"
fi
else
echo "$prompt"
fi

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env bash
set -euo pipefail
#
# mission-init.sh — Initialize a new orchestration mission
#
# Usage:
# mission-init.sh --name <name> [options]
#
# Options:
# --name <name> Mission name (required)
# --project <path> Project directory (default: CWD)
# --prefix <prefix> Task ID prefix (e.g., MS)
# --milestones <comma-list> Milestone names, comma-separated
# --quality-gates <command> Quality gate command string
# --version <semver> Milestone version (default: 0.0.1)
# --description <text> Mission description
# --force Overwrite existing active mission
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"
# ─── Parse arguments ─────────────────────────────────────────────────────────
NAME=""
PROJECT="."
PREFIX=""
MILESTONES=""
QUALITY_GATES=""
VERSION="0.0.1"
DESCRIPTION=""
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--name) NAME="$2"; shift 2 ;;
--project) PROJECT="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--milestones) MILESTONES="$2"; shift 2 ;;
--quality-gates) QUALITY_GATES="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--description) DESCRIPTION="$2"; shift 2 ;;
--force) FORCE=true; shift ;;
-h|--help)
cat <<'USAGE'
mission-init.sh — Initialize a new orchestration mission
Usage: mission-init.sh --name <name> [options]
Options:
--name <name> Mission name (required)
--project <path> Project directory (default: CWD)
--prefix <prefix> Task ID prefix (e.g., MS)
--milestones <comma-list> Milestone names, comma-separated
--quality-gates <command> Quality gate command string
--version <semver> Milestone version (default: 0.0.1)
--description <text> Mission description
--force Overwrite existing active mission
Example:
mosaic coord init \
--name "Security Remediation" \
--prefix SEC \
--milestones "Critical Fixes,High Priority,Code Quality" \
--quality-gates "pnpm lint && pnpm typecheck && pnpm test"
USAGE
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$NAME" ]]; then
echo -e "${C_RED}Error: --name is required${C_RESET}" >&2
exit 1
fi
_require_jq
# ─── Validate project ───────────────────────────────────────────────────────
od="$(orch_dir "$PROJECT")"
if [[ ! -d "$od" ]]; then
echo -e "${C_RED}Error: $od not found. Run 'mosaic bootstrap' first.${C_RESET}" >&2
exit 1
fi
# Check for existing active mission
mp="$(mission_path "$PROJECT")"
if [[ -f "$mp" ]]; then
existing_status="$(jq -r '.status // "inactive"' "$mp")"
if [[ "$existing_status" == "active" || "$existing_status" == "paused" ]] && [[ "$FORCE" != true ]]; then
existing_name="$(jq -r '.name // "unnamed"' "$mp")"
echo -e "${C_YELLOW}Active mission exists: $existing_name (status: $existing_status)${C_RESET}" >&2
echo "Use --force to overwrite." >&2
exit 1
fi
fi
# ─── Generate mission ID ────────────────────────────────────────────────────
MISSION_ID="$(slugify "$NAME")-$(date +%Y%m%d)"
# ─── Build milestones array ─────────────────────────────────────────────────
milestones_json="[]"
if [[ -n "$MILESTONES" ]]; then
IFS=',' read -ra ms_array <<< "$MILESTONES"
for i in "${!ms_array[@]}"; do
ms_name="$(echo "${ms_array[$i]}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
ms_id="phase-$(( i + 1 ))"
ms_branch="$(slugify "$ms_name")"
milestones_json="$(echo "$milestones_json" | jq \
--arg id "$ms_id" \
--arg name "$ms_name" \
--arg branch "$ms_branch" \
'. + [{
"id": $id,
"name": $name,
"status": "pending",
"branch": $branch,
"issue_ref": "",
"started_at": "",
"completed_at": ""
}]')"
done
fi
MILESTONE_COUNT="$(echo "$milestones_json" | jq 'length')"
# ─── Write mission.json ─────────────────────────────────────────────────────
mission_json="$(jq -n \
--arg mid "$MISSION_ID" \
--arg name "$NAME" \
--arg desc "$DESCRIPTION" \
--arg pp "$(cd "$PROJECT" && pwd)" \
--arg ts "$(iso_now)" \
--arg prefix "$PREFIX" \
--arg qg "$QUALITY_GATES" \
--arg ver "$VERSION" \
--argjson milestones "$milestones_json" \
'{
schema_version: 1,
mission_id: $mid,
name: $name,
description: $desc,
project_path: $pp,
created_at: $ts,
status: "active",
task_prefix: $prefix,
quality_gates: $qg,
milestone_version: $ver,
milestones: $milestones,
sessions: []
}')"
write_json "$mp" "$mission_json"
# ─── Scaffold MISSION-MANIFEST.md ───────────────────────────────────────────
manifest_path="$PROJECT/$MANIFEST_FILE"
mkdir -p "$(dirname "$manifest_path")"
if [[ ! -f "$manifest_path" ]] || [[ "$FORCE" == true ]]; then
# Build milestones table rows
ms_table=""
for i in $(seq 0 $(( MILESTONE_COUNT - 1 ))); do
ms_id="$(echo "$milestones_json" | jq -r ".[$i].id")"
ms_name="$(echo "$milestones_json" | jq -r ".[$i].name")"
ms_table+="| $(( i + 1 )) | $ms_id | $ms_name | pending | — | — | — | — |"$'\n'
done
cat > "$manifest_path" <<EOF
# Mission Manifest — $NAME
> Persistent document tracking full mission scope, status, and session history.
> Updated by the orchestrator at each phase transition and milestone completion.
## Mission
**ID:** $MISSION_ID
**Statement:** $DESCRIPTION
**Phase:** Intake
**Current Milestone:** —
**Progress:** 0 / $MILESTONE_COUNT milestones
**Status:** active
**Last Updated:** $(date -u +"%Y-%m-%d %H:%M UTC")
## Success Criteria
<!-- Define measurable success criteria here -->
## Milestones
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|---|-----|------|--------|--------|-------|---------|-----------|
$ms_table
## Deployment
| Target | URL | Method |
|--------|-----|--------|
| — | — | — |
## Token Budget
| Metric | Value |
|--------|-------|
| Budget | — |
| Used | 0 |
| Mode | normal |
## Session History
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|---------|---------|---------|----------|--------------|-----------|
## Scratchpad
Path: \`docs/scratchpads/$MISSION_ID.md\`
EOF
fi
# ─── Scaffold scratchpad ────────────────────────────────────────────────────
sp_dir="$PROJECT/$SCRATCHPAD_DIR"
sp_file="$sp_dir/$MISSION_ID.md"
mkdir -p "$sp_dir"
if [[ ! -f "$sp_file" ]]; then
cat > "$sp_file" <<EOF
# Mission Scratchpad — $NAME
> Append-only log. NEVER delete entries. NEVER overwrite sections.
> This is the orchestrator's working memory across sessions.
## Original Mission Prompt
\`\`\`
(Paste the mission prompt here on first session)
\`\`\`
## Planning Decisions
## Session Log
| Session | Date | Milestone | Tasks Done | Outcome |
|---------|------|-----------|------------|---------|
## Open Questions
## Corrections
EOF
fi
# ─── Scaffold TASKS.md if absent ────────────────────────────────────────────
tasks_path="$PROJECT/$TASKS_MD"
mkdir -p "$(dirname "$tasks_path")"
if [[ ! -f "$tasks_path" ]]; then
cat > "$tasks_path" <<EOF
# Tasks — $NAME
> Single-writer: orchestrator only. Workers read but never modify.
| id | status | milestone | description | pr | notes |
|----|--------|-----------|-------------|----|-------|
EOF
fi
# ─── Report ──────────────────────────────────────────────────────────────────
echo ""
echo -e "${C_GREEN}${C_BOLD}Mission initialized: $NAME${C_RESET}"
echo ""
echo -e " ${C_CYAN}Mission ID:${C_RESET} $MISSION_ID"
echo -e " ${C_CYAN}Milestones:${C_RESET} $MILESTONE_COUNT"
echo -e " ${C_CYAN}State:${C_RESET} $(mission_path "$PROJECT")"
echo -e " ${C_CYAN}Manifest:${C_RESET} $manifest_path"
echo -e " ${C_CYAN}Scratchpad:${C_RESET} $sp_file"
echo -e " ${C_CYAN}Tasks:${C_RESET} $tasks_path"
echo ""
echo "Next: Launch an agent session with 'mosaic claude' or generate a prompt with 'mosaic coord continue'"

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env bash
set -euo pipefail
#
# mission-status.sh — Show mission progress dashboard
#
# Usage:
# mission-status.sh [--project <path>] [--format table|json|markdown]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"
# ─── Parse arguments ─────────────────────────────────────────────────────────
PROJECT="."
FORMAT="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--format) FORMAT="$2"; shift 2 ;;
-h|--help)
echo "Usage: mission-status.sh [--project <path>] [--format table|json|markdown]"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
_require_jq
require_mission "$PROJECT"
# ─── Load data ───────────────────────────────────────────────────────────────
mission="$(load_mission "$PROJECT")"
mission_name="$(echo "$mission" | jq -r '.name')"
mission_id="$(echo "$mission" | jq -r '.mission_id')"
mission_status="$(echo "$mission" | jq -r '.status')"
version="$(echo "$mission" | jq -r '.milestone_version // "—"')"
created_at="$(echo "$mission" | jq -r '.created_at // "—"')"
session_count="$(echo "$mission" | jq '.sessions | length')"
milestone_count="$(echo "$mission" | jq '.milestones | length')"
completed_milestones="$(echo "$mission" | jq '[.milestones[] | select(.status == "completed")] | length')"
# Task counts
task_counts="$(count_tasks_md "$PROJECT")"
tasks_total="$(echo "$task_counts" | jq '.total')"
tasks_done="$(echo "$task_counts" | jq '.done')"
tasks_inprog="$(echo "$task_counts" | jq '.in_progress')"
tasks_pending="$(echo "$task_counts" | jq '.pending')"
tasks_blocked="$(echo "$task_counts" | jq '.blocked')"
tasks_failed="$(echo "$task_counts" | jq '.failed')"
# Next task
next_task="$(find_next_task "$PROJECT")"
# ─── JSON output ─────────────────────────────────────────────────────────────
if [[ "$FORMAT" == "json" ]]; then
echo "$mission" | jq \
--argjson tasks "$task_counts" \
--arg next "$next_task" \
'. + {task_counts: $tasks, next_task: $next}'
exit 0
fi
# ─── Progress bar ────────────────────────────────────────────────────────────
progress_bar() {
local done=$1
local total=$2
local width=30
if (( total == 0 )); then
printf "[%${width}s]" ""
return
fi
local filled=$(( (done * width) / total ))
local empty=$(( width - filled ))
local bar=""
for (( i=0; i<filled; i++ )); do bar+="="; done
if (( empty > 0 && filled > 0 )); then
bar+=">"
empty=$(( empty - 1 ))
fi
for (( i=0; i<empty; i++ )); do bar+="."; done
printf "[%s]" "$bar"
}
# ─── Table / Markdown output ────────────────────────────────────────────────
# Header
echo ""
echo "=================================================="
echo -e " ${C_BOLD}Mission: $mission_name${C_RESET}"
echo -e " Status: ${C_CYAN}$mission_status${C_RESET} Version: $version"
echo -e " Started: ${created_at:0:10} Sessions: $session_count"
echo "=================================================="
echo ""
# Milestones
echo -e "${C_BOLD}Milestones:${C_RESET}"
for i in $(seq 0 $(( milestone_count - 1 ))); do
ms_id="$(echo "$mission" | jq -r ".milestones[$i].id")"
ms_name="$(echo "$mission" | jq -r ".milestones[$i].name")"
ms_status="$(echo "$mission" | jq -r ".milestones[$i].status")"
ms_issue="$(echo "$mission" | jq -r ".milestones[$i].issue_ref // \"\"")"
case "$ms_status" in
completed) icon="${C_GREEN}[x]${C_RESET}" ;;
in-progress) icon="${C_YELLOW}[>]${C_RESET}" ;;
blocked) icon="${C_RED}[!]${C_RESET}" ;;
*) icon="${C_DIM}[ ]${C_RESET}" ;;
esac
issue_str=""
[[ -n "$ms_issue" ]] && issue_str="$ms_issue"
printf " %b %-40s %s\n" "$icon" "$ms_name" "$issue_str"
done
echo ""
# Tasks progress
pct=0
(( tasks_total > 0 )) && pct=$(( (tasks_done * 100) / tasks_total ))
echo -e "${C_BOLD}Tasks:${C_RESET} $(progress_bar "$tasks_done" "$tasks_total") ${tasks_done}/${tasks_total} (${pct}%)"
echo -e " done: ${C_GREEN}$tasks_done${C_RESET} in-progress: ${C_YELLOW}$tasks_inprog${C_RESET} pending: $tasks_pending blocked: ${C_RED}$tasks_blocked${C_RESET} failed: ${C_RED}$tasks_failed${C_RESET}"
echo ""
# Session history (last 5)
if (( session_count > 0 )); then
echo -e "${C_BOLD}Recent Sessions:${C_RESET}"
start_idx=$(( session_count > 5 ? session_count - 5 : 0 ))
for i in $(seq "$start_idx" $(( session_count - 1 ))); do
s_id="$(echo "$mission" | jq -r ".sessions[$i].session_id")"
s_rt="$(echo "$mission" | jq -r ".sessions[$i].runtime // \"—\"")"
s_start="$(echo "$mission" | jq -r ".sessions[$i].started_at // \"\"")"
s_end="$(echo "$mission" | jq -r ".sessions[$i].ended_at // \"\"")"
s_reason="$(echo "$mission" | jq -r ".sessions[$i].ended_reason // \"—\"")"
s_last="$(echo "$mission" | jq -r ".sessions[$i].last_task_id // \"—\"")"
duration_str="—"
if [[ -n "$s_start" && -n "$s_end" && "$s_end" != "" ]]; then
s_epoch="$(iso_to_epoch "$s_start")"
e_epoch="$(iso_to_epoch "$s_end")"
if (( e_epoch > 0 && s_epoch > 0 )); then
duration_str="$(format_duration $(( e_epoch - s_epoch )))"
fi
fi
printf " %-10s %-8s %-10s %-18s → %s\n" "$s_id" "$s_rt" "$duration_str" "$s_reason" "$s_last"
done
echo ""
fi
# Current session check
lock_data=""
if lock_data="$(session_lock_read "$PROJECT" 2>/dev/null)"; then
lock_pid="$(echo "$lock_data" | jq -r '.pid // 0')"
lock_rt="$(echo "$lock_data" | jq -r '.runtime // "unknown"')"
lock_start="$(echo "$lock_data" | jq -r '.started_at // ""')"
if is_pid_alive "$lock_pid"; then
dur=0
if [[ -n "$lock_start" ]]; then
dur=$(( $(epoch_now) - $(iso_to_epoch "$lock_start") ))
fi
echo -e "${C_GREEN}Current: running ($lock_rt, PID $lock_pid, $(format_duration "$dur"))${C_RESET}"
else
echo -e "${C_RED}Stale session lock: $lock_rt (PID $lock_pid, not running)${C_RESET}"
echo " Run: mosaic coord resume --clean-lock"
fi
else
echo -e "${C_DIM}No active session.${C_RESET}"
fi
[[ -n "$next_task" ]] && echo -e "Next unblocked task: ${C_CYAN}$next_task${C_RESET}"
echo ""

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
#
# session-resume.sh — Crash recovery for dead orchestrator sessions
#
# Usage:
# session-resume.sh [--project <path>] [--clean-lock]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"
# ─── Parse arguments ─────────────────────────────────────────────────────────
PROJECT="."
CLEAN_LOCK=false
while [[ $# -gt 0 ]]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--clean-lock) CLEAN_LOCK=true; shift ;;
-h|--help)
echo "Usage: session-resume.sh [--project <path>] [--clean-lock]"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
_require_jq
# ─── Check session lock ─────────────────────────────────────────────────────
lock_data=""
has_lock=false
if lock_data="$(session_lock_read "$PROJECT" 2>/dev/null)"; then
has_lock=true
fi
if [[ "$has_lock" == true ]]; then
lock_pid="$(echo "$lock_data" | jq -r '.pid // 0')"
lock_sid="$(echo "$lock_data" | jq -r '.session_id // "unknown"')"
lock_rt="$(echo "$lock_data" | jq -r '.runtime // "unknown"')"
lock_start="$(echo "$lock_data" | jq -r '.started_at // ""')"
lock_milestone="$(echo "$lock_data" | jq -r '.milestone_id // ""')"
if is_pid_alive "$lock_pid"; then
echo -e "${C_YELLOW}Session $lock_sid is still running (PID $lock_pid).${C_RESET}"
echo "Use 'mosaic coord status' to check session health."
exit 0
fi
# Session is dead
echo ""
echo -e "${C_RED}${C_BOLD}CRASH RECOVERY — Session $lock_sid ($lock_rt)${C_RESET}"
echo "==========================================="
echo ""
if [[ -n "$lock_start" ]]; then
echo -e " ${C_CYAN}Session started:${C_RESET} $lock_start"
fi
echo -e " ${C_CYAN}Session died:${C_RESET} PID $lock_pid is not running"
[[ -n "$lock_milestone" ]] && echo -e " ${C_CYAN}Active milestone:${C_RESET} $lock_milestone"
echo ""
else
# No lock — check mission.json for last session info
if [[ -f "$(mission_path "$PROJECT")" ]]; then
mission="$(load_mission "$PROJECT")"
session_count="$(echo "$mission" | jq '.sessions | length')"
if (( session_count > 0 )); then
last_idx=$(( session_count - 1 ))
last_sid="$(echo "$mission" | jq -r ".sessions[$last_idx].session_id")"
last_reason="$(echo "$mission" | jq -r ".sessions[$last_idx].ended_reason // \"unknown\"")"
echo -e "${C_DIM}No session lock found. Last session: $last_sid (ended: $last_reason)${C_RESET}"
echo "Use 'mosaic coord continue' to generate a continuation prompt."
exit 0
fi
fi
echo -e "${C_DIM}No session state found.${C_RESET}"
exit 4
fi
# ─── Detect dirty state ─────────────────────────────────────────────────────
echo -e "${C_BOLD}Dirty State:${C_RESET}"
dirty_files=""
if git -C "$PROJECT" rev-parse --is-inside-work-tree &>/dev/null; then
dirty_files="$(git -C "$PROJECT" status --porcelain 2>/dev/null || true)"
fi
if [[ -n "$dirty_files" ]]; then
echo " Modified files:"
echo "$dirty_files" | head -20 | while IFS= read -r line; do
echo " $line"
done
file_count="$(echo "$dirty_files" | wc -l)"
if (( file_count > 20 )); then
echo " ... and $(( file_count - 20 )) more"
fi
else
echo -e " ${C_GREEN}Working tree is clean.${C_RESET}"
fi
# Check for in-progress tasks
inprog_count=0
task_counts="$(count_tasks_md "$PROJECT")"
inprog_count="$(echo "$task_counts" | jq '.in_progress')"
if (( inprog_count > 0 )); then
echo -e " ${C_YELLOW}$inprog_count task(s) still marked in-progress in TASKS.md${C_RESET}"
fi
echo ""
# ─── Recovery actions ────────────────────────────────────────────────────────
echo -e "${C_BOLD}Recovery Actions:${C_RESET}"
if [[ -n "$dirty_files" ]]; then
echo " 1. Review changes: git diff"
echo " 2. If good: git add -A && git commit -m \"wip: partial work from crashed session\""
echo " 3. If bad: git checkout ."
fi
echo " 4. Clean lock: mosaic coord resume --clean-lock"
echo " 5. Generate prompt: mosaic coord continue"
echo ""
# ─── Clean lock if requested ─────────────────────────────────────────────────
if [[ "$CLEAN_LOCK" == true ]]; then
echo -e "${C_CYAN}Cleaning session lock...${C_RESET}"
# Update mission.json with crash info
mp="$(mission_path "$PROJECT")"
if [[ -f "$mp" && "$has_lock" == true ]]; then
updated="$(jq \
--arg sid "$lock_sid" \
--arg ts "$(iso_now)" \
'(.sessions[] | select(.session_id == $sid)) |= . + {
ended_at: $ts,
ended_reason: "crashed"
}' "$mp")"
write_json "$mp" "$updated"
echo " Updated mission.json: session $lock_sid marked as crashed"
fi
session_lock_clear "$PROJECT"
echo " Cleared session.lock"
echo ""
echo -e "${C_GREEN}Lock cleared. Generate continuation prompt with: mosaic coord continue${C_RESET}"
fi
# ─── Generate resume prompt ─────────────────────────────────────────────────
if [[ "$CLEAN_LOCK" != true ]]; then
echo "---"
echo ""
echo -e "${C_BOLD}Resume Prompt (paste to new session):${C_RESET}"
echo ""
mission_name=""
mission_id=""
if [[ -f "$(mission_path "$PROJECT")" ]]; then
mission="$(load_mission "$PROJECT")"
mission_name="$(echo "$mission" | jq -r '.name')"
mission_id="$(echo "$mission" | jq -r '.mission_id')"
quality_gates="$(echo "$mission" | jq -r '.quality_gates // "—"')"
project_path="$(echo "$mission" | jq -r '.project_path')"
fi
task_counts="$(count_tasks_md "$PROJECT")"
tasks_done="$(echo "$task_counts" | jq '.done')"
tasks_total="$(echo "$task_counts" | jq '.total')"
next_task="$(find_next_task "$PROJECT")"
cat <<EOF
## Crash Recovery Mission
Recovering **${mission_name:-Unknown Mission}** from crashed session ${lock_sid:-unknown}.
### WARNING: Dirty State Detected
The previous session left uncommitted changes. Before continuing:
1. Run \`git diff\` to review uncommitted changes
2. Decide: commit (if good) or discard (if broken)
3. Then proceed with the mission
## Setup
- **Project:** ${project_path:-$PROJECT}
- **State:** docs/TASKS.md (${tasks_done}/${tasks_total} tasks complete)
- **Manifest:** docs/MISSION-MANIFEST.md
- **Scratchpad:** docs/scratchpads/${mission_id:-mission}.md
- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md
- **Quality gates:** ${quality_gates:-—}
## Resume Point
- **Next task:** ${next_task:-check TASKS.md}
## Instructions
1. Read \`docs/MISSION-MANIFEST.md\` for mission scope
2. Read \`docs/scratchpads/${mission_id:-mission}.md\` for session history
3. Review and resolve any uncommitted changes first
4. Read \`docs/TASKS.md\` for current task state
5. Continue execution from the next pending task
6. You are the SOLE writer of \`docs/TASKS.md\`
EOF
fi

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
#
# session-status.sh — Check agent session health
#
# Usage:
# session-status.sh [--project <path>] [--format table|json]
#
# Exit codes:
# 0 = running
# 2 = stale (recently died)
# 3 = dead (no longer running)
# 4 = no session
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"
# ─── Parse arguments ─────────────────────────────────────────────────────────
PROJECT="."
FORMAT="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--format) FORMAT="$2"; shift 2 ;;
-h|--help)
echo "Usage: session-status.sh [--project <path>] [--format table|json]"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
_require_jq
# ─── Check session lock ─────────────────────────────────────────────────────
lock_data=""
if ! lock_data="$(session_lock_read "$PROJECT")"; then
if [[ "$FORMAT" == "json" ]]; then
echo '{"status":"no-session"}'
else
echo -e "${C_DIM}No active session.${C_RESET}"
fi
exit 4
fi
# Parse lock
session_id="$(echo "$lock_data" | jq -r '.session_id // "unknown"')"
runtime="$(echo "$lock_data" | jq -r '.runtime // "unknown"')"
pid="$(echo "$lock_data" | jq -r '.pid // 0')"
started_at="$(echo "$lock_data" | jq -r '.started_at // ""')"
milestone_id="$(echo "$lock_data" | jq -r '.milestone_id // ""')"
# ─── Determine status ───────────────────────────────────────────────────────
status="unknown"
exit_code=1
if is_pid_alive "$pid"; then
status="running"
exit_code=0
else
# PID is dead — check how recently
last_act="$(last_activity_time "$PROJECT")"
now="$(epoch_now)"
age=$(( now - last_act ))
if (( age < STALE_THRESHOLD )); then
status="stale"
exit_code=2
elif (( age < DEAD_THRESHOLD )); then
status="stale"
exit_code=2
else
status="dead"
exit_code=3
fi
fi
# ─── Gather supplementary info ──────────────────────────────────────────────
duration_secs=0
if [[ -n "$started_at" ]]; then
start_epoch="$(iso_to_epoch "$started_at")"
now="$(epoch_now)"
duration_secs=$(( now - start_epoch ))
fi
last_act="$(last_activity_time "$PROJECT")"
# Current milestone from mission.json
current_ms=""
if [[ -f "$(mission_path "$PROJECT")" ]]; then
current_ms="$(current_milestone_id "$PROJECT")"
if [[ -n "$current_ms" ]]; then
ms_name="$(milestone_name "$PROJECT" "$current_ms")"
[[ -n "$ms_name" ]] && current_ms="$current_ms ($ms_name)"
fi
fi
# Next task from TASKS.md
next_task="$(find_next_task "$PROJECT")"
# ─── Output ──────────────────────────────────────────────────────────────────
if [[ "$FORMAT" == "json" ]]; then
jq -n \
--arg status "$status" \
--arg session_id "$session_id" \
--arg runtime "$runtime" \
--arg pid "$pid" \
--arg started_at "$started_at" \
--arg duration "$duration_secs" \
--arg milestone "$current_ms" \
--arg next_task "$next_task" \
--arg last_activity "$last_act" \
'{
status: $status,
session_id: $session_id,
runtime: $runtime,
pid: ($pid | tonumber),
started_at: $started_at,
duration_seconds: ($duration | tonumber),
milestone: $milestone,
next_task: $next_task,
last_activity_epoch: ($last_activity | tonumber)
}'
else
# Color the status
case "$status" in
running) status_color="${C_GREEN}RUNNING${C_RESET}" ;;
stale) status_color="${C_YELLOW}STALE${C_RESET}" ;;
dead) status_color="${C_RED}DEAD${C_RESET}" ;;
*) status_color="$status" ;;
esac
echo ""
echo -e " Session Status: $status_color ($runtime)"
echo -e " ${C_CYAN}Session ID:${C_RESET} $session_id"
echo -e " ${C_CYAN}Started:${C_RESET} $started_at ($(format_duration "$duration_secs"))"
echo -e " ${C_CYAN}PID:${C_RESET} $pid"
[[ -n "$current_ms" ]] && echo -e " ${C_CYAN}Milestone:${C_RESET} $current_ms"
[[ -n "$next_task" ]] && echo -e " ${C_CYAN}Next task:${C_RESET} $next_task"
echo -e " ${C_CYAN}Last activity:${C_RESET} $(format_ago "$last_act")"
echo ""
if [[ "$status" == "stale" || "$status" == "dead" ]]; then
echo -e " ${C_YELLOW}Session is no longer running.${C_RESET}"
echo " Recovery: mosaic coord resume"
echo " Continue: mosaic coord continue"
echo ""
fi
fi
exit "$exit_code"