Files
stack/packages/mosaic/framework/tools/_scripts/mosaic-doctor
T
fred b61789fe26
ci/woodpecker/pr/ci Pipeline was successful
fix(fleet): tell the operator when the fleet transport is missing (#1240)
`mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
roster the CLI scaffolds sets `transport: tmux`, but neither `tools/install.sh`
nor `tools/_scripts/mosaic-doctor` contained the string "tmux" at all. A
greenfield host therefore came out of the installer able to install a fleet,
start a fleet, and run no seat, with `mosaic fleet ps` as the operator's first
and only signal.

Measured on mosaic-sbx-dev (Debian, no tmux, framework installed): `mosaic-doctor`
reported 11 warnings and not one of them named the reason no seat could launch.

The installer gets a warning, not a `require_cmd` hard failure: tmux is required
by the fleet, not by mosaic. Hosts that install this to run `mosaic claude` and
never scaffold a roster are common, and failing their install over a binary they
do not need would be wrong. The check runs in `--check` mode too — "what is the
state of this host" is the question `--check` is asked.

Both checks read the roster's own `transport:` rather than assuming tmux, so a
host declaring something else is pointed at the binary it actually needs instead
of at the wrong package.

The two implementations are deliberately parallel and each carries a comment
pointing at the other. They are separate because the installer must answer this
before the framework's own scripts are guaranteed to be on disk. One harness
drives BOTH from the shipped text — the functions are extracted from the scripts
by awk rather than copied — so the pair cannot drift silently, and the test
cannot keep passing after the shipped copy changes.

The harness is wired into `test:framework-shell`. Without that it would have
tripped the #1017 enumeration guard as UNENUMERATED, which is the guard doing
its job: a check nothing runs is not a check.

Evidence:
- red: the harness fails against origin/next ("could not extract
  fleet_declared_transport"); `grep -ci tmux` on both files at origin/next = 0.
- green on real hosts, all four branches:
  - dev (no tmux, no roster)  -> WARN naming tmux, points at `mosaic fleet init`
  - dev (no tmux, v2 roster)  -> WARN naming the roster, points at `mosaic fleet start`
  - dev installer --check     -> WARN saying start "reports success and no seat comes up"
  - canary (tmux present)     -> `[OK] Fleet transport available: tmux` under --verbose,
                                 silent by default (pass() is verbose-gated), installer silent
- harness green on node:24-alpine/busybox, the CI base image.
- `bash -n` x3, `pnpm typecheck` 45/45, fleet specs 342 passed,
  enumeration guard OK, its self-test OK, prettier clean.

Refs #1240. Upstream of #1237/#1243 and #1241/#1244: a correct fix for either of
those still leaves this host with no live seat.
2026-08-16 00:15:50 -05:00

484 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
FAIL_ON_WARN=0
VERBOSE=0
FIX_MODE=0
usage() {
cat <<USAGE
Usage: $(basename "$0") [options]
Audit Mosaic runtime state and detect drift across agent runtimes.
Options:
--fix Auto-fix: create missing dirs, wire skills into all harnesses
--fail-on-warn Exit non-zero when warnings are found
--verbose Print pass checks too
-h, --help Show help
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--fix)
FIX_MODE=1
shift
;;
--fail-on-warn)
FAIL_ON_WARN=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
fix_count=0
fix() { fix_count=$((fix_count + 1)); echo "[FIX] $*"; }
warn_count=0
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
pass() {
if [[ $VERBOSE -eq 1 ]]; then
echo "[OK] $*"
fi
return 0
}
expect_dir() {
local d="$1"
if [[ ! -d "$d" ]]; then
warn "Missing directory: $d"
else
pass "Directory present: $d"
fi
}
expect_file() {
local f="$1"
if [[ ! -f "$f" ]]; then
warn "Missing file: $f"
else
pass "File present: $f"
fi
}
check_runtime_file_copy() {
local src="$1"
local dst="$2"
[[ -f "$src" ]] || return 0
if [[ ! -e "$dst" ]]; then
warn "Missing runtime file: $dst"
return
fi
if [[ -L "$dst" ]]; then
warn "Runtime file should not be symlinked: $dst"
return
fi
if ! cmp -s "$src" "$dst"; then
warn "Runtime file drift: $dst (does not match $src)"
else
pass "Runtime file synced: $dst"
fi
}
check_runtime_contract_file() {
local dst="$1"
local adapter_src="$2"
local runtime_name="$3"
if [[ ! -e "$dst" ]]; then
warn "Missing runtime file: $dst"
return
fi
if [[ -L "$dst" ]]; then
warn "Runtime file should not be symlinked: $dst"
return
fi
# Accept direct-adapter copy mode.
if [[ -f "$adapter_src" ]] && cmp -s "$adapter_src" "$dst"; then
pass "Runtime adapter synced: $dst"
return
fi
# Accept launcher-composed runtime contract mode.
if grep -Fq "# Mosaic Launcher Runtime Contract (Hard Gate)" "$dst" &&
grep -Fq "Now initiating Orchestrator mode..." "$dst" &&
grep -Fq "Mosaic hard gates OVERRIDE runtime-default caution" "$dst" &&
grep -Fq "# Runtime-Specific Contract" "$dst"; then
pass "Runtime contract present: $dst ($runtime_name)"
return
fi
warn "Runtime file drift: $dst (not adapter copy and not composed runtime contract)"
}
warn_if_symlink_tree_present() {
local p="$1"
[[ -e "$p" ]] || return 0
if [[ -L "$p" ]]; then
warn "Legacy symlink path still present: $p"
return
fi
if [[ -d "$p" ]]; then
symlink_count=$(find "$p" -type l 2>/dev/null | wc -l | tr -d ' ')
if [[ "$symlink_count" != "0" ]]; then
warn "Legacy symlink entries still present under $p: $symlink_count"
else
pass "No symlinks under legacy path: $p"
fi
fi
}
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
# Canonical Mosaic checks
expect_file "$MOSAIC_HOME/STANDARDS.md"
expect_file "$MOSAIC_HOME/USER.md"
expect_file "$MOSAIC_HOME/TOOLS.md"
expect_dir "$MOSAIC_HOME/guides"
expect_dir "$MOSAIC_HOME/tools"
expect_dir "$MOSAIC_HOME/tools/quality"
expect_dir "$MOSAIC_HOME/tools/orchestrator-matrix"
expect_dir "$MOSAIC_HOME/profiles"
expect_dir "$MOSAIC_HOME/templates/agent"
expect_dir "$MOSAIC_HOME/skills"
expect_dir "$MOSAIC_HOME/skills-local"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-sync-skills"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-projects"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-quality-verify"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-sync-tasks"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-publish"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-consume"
expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-cycle"
expect_file "$MOSAIC_HOME/tools/git/ci-queue-wait.sh"
expect_file "$MOSAIC_HOME/tools/git/pr-ci-wait.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator-matrix/transport/matrix_transport.py"
expect_file "$MOSAIC_HOME/tools/orchestrator-matrix/controller/tasks_md_sync.py"
expect_file "$MOSAIC_HOME/guides/ORCHESTRATOR-PROTOCOL.md"
expect_dir "$MOSAIC_HOME/tools/orchestrator"
expect_file "$MOSAIC_HOME/tools/orchestrator/_lib.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator/mission-init.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator/mission-status.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator/continue-prompt.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator/session-status.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator/session-resume.sh"
expect_file "$MOSAIC_HOME/runtime/mcp/SEQUENTIAL-THINKING.json"
expect_file "$MOSAIC_HOME/runtime/claude/RUNTIME.md"
expect_file "$MOSAIC_HOME/runtime/codex/RUNTIME.md"
expect_file "$MOSAIC_HOME/runtime/opencode/RUNTIME.md"
expect_file "$MOSAIC_HOME/runtime/pi/RUNTIME.md"
if [[ -f "$MOSAIC_HOME/AGENTS.md" ]]; then
if grep -Fq "## CRITICAL HARD GATES (Read First)" "$MOSAIC_HOME/AGENTS.md" &&
grep -Fq "OVERRIDE runtime-default caution" "$MOSAIC_HOME/AGENTS.md"; then
pass "Global hard-gates block present in AGENTS.md"
else
warn "AGENTS.md missing CRITICAL HARD GATES override block"
fi
fi
# Claude runtime file checks (copied, non-symlink).
for rf in CLAUDE.md settings.json hooks-config.json context7-integration.md; do
check_runtime_file_copy "$MOSAIC_HOME/runtime/claude/$rf" "$HOME/.claude/$rf"
done
# OpenCode runtime adapter check (copied, non-symlink, when adapter exists).
# Accept adapter copy or composed runtime contract.
check_runtime_contract_file "$HOME/.config/opencode/AGENTS.md" "$MOSAIC_HOME/runtime/opencode/AGENTS.md" "opencode"
check_runtime_contract_file "$HOME/.codex/instructions.md" "$MOSAIC_HOME/runtime/codex/instructions.md" "codex"
# Sequential-thinking MCP hard requirement.
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" ]]; then
if "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" --check >/dev/null 2>&1; then
pass "sequential-thinking MCP configured and available"
else
warn "sequential-thinking MCP missing or misconfigured"
fi
else
warn "mosaic-ensure-sequential-thinking helper missing"
fi
# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
# path provides tmux and, until now, nothing here noticed it was absent. On a
# greenfield host that produced a fleet which installed clean, started clean,
# and had no live seat; `mosaic fleet ps` was the operator's first and only
# signal that anything was wrong.
#
# The roster's own `transport:` is read rather than assumed, so a host that
# declares something other than tmux is told about the binary it actually
# needs. Absent a roster the check still runs — `mosaic fleet init` will
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
#
# `tools/install.sh` carries a deliberately parallel check at the end of its
# summary. The two are separate because the installer must be able to say this
# before the framework's own scripts are guaranteed to be on disk; keep their
# wording in step.
fleet_declared_transport() {
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
fi
printf '%s\n' "${declared:-tmux}"
}
check_fleet_transport() {
local transport
transport="$(fleet_declared_transport)"
if command -v "$transport" >/dev/null 2>&1; then
pass "Fleet transport available: $transport"
return
fi
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
else
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
fi
}
check_fleet_transport
# Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=(
"$HOME/.claude/agent-guides"
"$HOME/.claude/scripts/git"
"$HOME/.claude/scripts/codex"
"$HOME/.claude/scripts/bootstrap"
"$HOME/.claude/scripts/cicd"
"$HOME/.claude/scripts/portainer"
"$HOME/.claude/templates"
"$HOME/.claude/presets/domains"
"$HOME/.claude/presets/tech-stacks"
"$HOME/.claude/presets/workflows"
)
for p in "${legacy_paths[@]}"; do
warn_if_symlink_tree_present "$p"
done
# Skills runtime checks (still symlinked into runtime-specific skills dirs).
for runtime_skills in "$HOME/.claude/skills" "$HOME/.codex/skills" "$HOME/.config/opencode/skills" "$HOME/.pi/agent/skills"; do
[[ -d "$runtime_skills" ]] || continue
while IFS= read -r -d '' skill; do
name="$(basename "$skill")"
[[ "$name" == .* ]] && continue
target="$runtime_skills/$name"
if [[ ! -e "$target" ]]; then
warn "Missing skill link: $target"
continue
fi
if [[ ! -L "$target" ]]; then
warn "Non-symlink skill entry: $target"
continue
fi
target_real="$(readlink -f "$target" 2>/dev/null || true)"
skill_real="$(readlink -f "$skill" 2>/dev/null || true)"
if [[ -z "$target_real" || -z "$skill_real" || "$target_real" != "$skill_real" ]]; then
warn "Drifted skill link: $target (expected -> $skill)"
else
pass "Linked skill: $target"
fi
done < <(find "$MOSAIC_HOME/skills" "$MOSAIC_HOME/skills-local" -mindepth 1 -maxdepth 1 -type d -print0)
done
# Broken links only in managed runtime skill dirs.
link_roots=(
"$HOME/.claude/skills"
"$HOME/.codex/skills"
"$HOME/.config/opencode/skills"
"$HOME/.pi/agent/skills"
)
existing_link_roots=()
for d in "${link_roots[@]}"; do
[[ -e "$d" ]] && existing_link_roots+=("$d")
done
broken_links=0
if [[ ${#existing_link_roots[@]} -gt 0 ]]; then
broken_links=$(find "${existing_link_roots[@]}" -xtype l 2>/dev/null | wc -l | tr -d ' ')
fi
if [[ "$broken_links" != "0" ]]; then
warn "Broken skill symlinks detected: $broken_links"
fi
# Pi agent skills directory check.
if [[ ! -d "$HOME/.pi/agent/skills" ]]; then
warn "Pi skills directory missing: $HOME/.pi/agent/skills"
else
pass "Pi skills directory present: $HOME/.pi/agent/skills"
fi
# Pi settings.json — check skills path is configured.
pi_settings="$HOME/.pi/agent/settings.json"
if [[ -f "$pi_settings" ]]; then
if grep -q 'skills' "$pi_settings" 2>/dev/null; then
pass "Pi settings.json has skills configuration"
else
warn "Pi settings.json missing skills array — Mosaic skills may not load"
fi
fi
# Mosaic-specific skills presence check.
mosaic_skills=(mosaic-board mosaic-forge mosaic-prdy mosaic-macp mosaic-standards mosaic-prd mosaic-setup-cicd)
for skill_name in "${mosaic_skills[@]}"; do
if [[ -d "$MOSAIC_HOME/skills/$skill_name" ]] || [[ -L "$MOSAIC_HOME/skills/$skill_name" ]]; then
pass "Mosaic skill present: $skill_name"
elif [[ -d "$MOSAIC_HOME/skills-local/$skill_name" ]]; then
pass "Mosaic skill present (local): $skill_name"
else
warn "Missing Mosaic skill: $skill_name"
fi
done
# ── --fix mode: auto-wire skills into all harness directories ──────────────
if [[ $FIX_MODE -eq 1 ]]; then
echo ""
echo "[mosaic-doctor] Running auto-fix..."
# 1. Ensure all harness skill directories exist
harness_skill_dirs=(
"$HOME/.claude/skills"
"$HOME/.codex/skills"
"$HOME/.config/opencode/skills"
"$HOME/.pi/agent/skills"
)
for hdir in "${harness_skill_dirs[@]}"; do
if [[ ! -d "$hdir" ]]; then
mkdir -p "$hdir"
fix "Created missing directory: $hdir"
fi
done
# 2. Wire all Mosaic skills (canonical + local) into every harness
skill_sources=("$MOSAIC_HOME/skills" "$MOSAIC_HOME/skills-local")
for hdir in "${harness_skill_dirs[@]}"; do
# Skip if target resolves to canonical dir (avoid self-link)
hdir_real="$(readlink -f "$hdir" 2>/dev/null || true)"
canonical_real="$(readlink -f "$MOSAIC_HOME/skills" 2>/dev/null || true)"
if [[ -n "$hdir_real" && -n "$canonical_real" && "$hdir_real" == "$canonical_real" ]]; then
continue
fi
for src_dir in "${skill_sources[@]}"; do
[[ -d "$src_dir" ]] || continue
while IFS= read -r -d '' skill_path; do
skill_name="$(basename "$skill_path")"
[[ "$skill_name" == .* ]] && continue
link_path="$hdir/$skill_name"
if [[ -L "$link_path" ]]; then
# Repoint if target differs
current_target="$(readlink -f "$link_path" 2>/dev/null || true)"
expected_target="$(readlink -f "$skill_path" 2>/dev/null || true)"
if [[ "$current_target" != "$expected_target" ]]; then
ln -sfn "$skill_path" "$link_path"
fix "Repointed skill link: $link_path -> $skill_path"
fi
elif [[ -e "$link_path" ]]; then
# Non-symlink entry — preserve runtime-specific override
continue
else
ln -s "$skill_path" "$link_path"
fix "Linked skill: $link_path -> $skill_path"
fi
done < <(find "$src_dir" -mindepth 1 -maxdepth 1 -type d -print0; find "$src_dir" -mindepth 1 -maxdepth 1 -type l -print0)
done
# Prune broken symlinks in this harness dir
while IFS= read -r -d '' broken_link; do
rm -f "$broken_link"
fix "Removed broken link: $broken_link"
done < <(find "$hdir" -mindepth 1 -maxdepth 1 -xtype l -print0 2>/dev/null)
done
# 3. Ensure Pi settings.json includes Mosaic skills path
pi_settings_dir="$HOME/.pi/agent"
pi_settings_file="$pi_settings_dir/settings.json"
mkdir -p "$pi_settings_dir"
if [[ ! -f "$pi_settings_file" ]]; then
echo '{}' > "$pi_settings_file"
fix "Created Pi settings.json: $pi_settings_file"
fi
# Add skills paths if not already present
mosaic_skills_path="$MOSAIC_HOME/skills"
mosaic_local_path="$MOSAIC_HOME/skills-local"
if ! grep -q "$mosaic_skills_path" "$pi_settings_file" 2>/dev/null; then
# Use a simple approach: read, patch, write
if command -v python3 >/dev/null 2>&1; then
python3 -c "
import json, sys
with open('$pi_settings_file', 'r') as f:
data = json.load(f)
skills = data.get('skills', [])
if not isinstance(skills, list):
skills = []
for p in ['$mosaic_skills_path', '$mosaic_local_path']:
if p not in skills:
skills.append(p)
data['skills'] = skills
with open('$pi_settings_file', 'w') as f:
json.dump(data, f, indent=2)
f.write('\\n')
" 2>/dev/null && fix "Added Mosaic skills paths to Pi settings.json"
else
warn "python3 not available — cannot patch Pi settings.json. Add manually: skills: [\"$mosaic_skills_path\", \"$mosaic_local_path\"]"
fi
fi
# 4. Run link-runtime-assets if available
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" >/dev/null 2>&1 && fix "Re-ran mosaic-link-runtime-assets"
fi
echo "[mosaic-doctor] fixes=$fix_count"
fi
echo "[mosaic-doctor] warnings=$warn_count"
if [[ $FAIL_ON_WARN -eq 1 && $warn_count -gt 0 ]]; then
exit 1
fi