#!/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 <&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 # 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-jarvis 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