# mosaic-doctor.ps1 # Audits Mosaic runtime state and detects drift across agent runtimes. # PowerShell equivalent of mosaic-doctor (bash). $ErrorActionPreference = "Stop" param( [switch]$FailOnWarn, [switch]$Verbose, [switch]$Help ) $MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" } if ($Help) { Write-Host @" Usage: mosaic-doctor.ps1 [-FailOnWarn] [-Verbose] [-Help] Audit Mosaic runtime state and detect drift across agent runtimes. "@ exit 0 } $script:warnCount = 0 function Warn { param([string]$Message) $script:warnCount++ Write-Host "[WARN] $Message" -ForegroundColor Yellow } function Pass { param([string]$Message) if ($Verbose) { Write-Host "[OK] $Message" -ForegroundColor Green } } function Expect-Dir { param([string]$Path) if (-not (Test-Path $Path -PathType Container)) { Warn "Missing directory: $Path" } else { Pass "Directory present: $Path" } } function Expect-File { param([string]$Path) if (-not (Test-Path $Path -PathType Leaf)) { Warn "Missing file: $Path" } else { Pass "File present: $Path" } } function Check-RuntimeFileCopy { param([string]$Src, [string]$Dst) if (-not (Test-Path $Src)) { return } if (-not (Test-Path $Dst)) { Warn "Missing runtime file: $Dst" return } $item = Get-Item $Dst -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { Warn "Runtime file should not be symlinked: $Dst" return } $srcHash = (Get-FileHash $Src -Algorithm SHA256).Hash $dstHash = (Get-FileHash $Dst -Algorithm SHA256).Hash if ($srcHash -ne $dstHash) { Warn "Runtime file drift: $Dst (does not match $Src)" } else { Pass "Runtime file synced: $Dst" } } function Check-RuntimeContractFile { param([string]$Dst, [string]$AdapterSrc, [string]$RuntimeName) if (-not (Test-Path $Dst)) { Warn "Missing runtime file: $Dst" return } $item = Get-Item $Dst -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { Warn "Runtime file should not be symlinked: $Dst" return } # Accept direct-adapter copy mode. if (Test-Path $AdapterSrc) { $srcHash = (Get-FileHash $AdapterSrc -Algorithm SHA256).Hash $dstHash = (Get-FileHash $Dst -Algorithm SHA256).Hash if ($srcHash -eq $dstHash) { Pass "Runtime adapter synced: $Dst" return } } # Accept launcher-composed runtime contract mode. $content = Get-Content $Dst -Raw if ( $content -match [regex]::Escape("# Mosaic Launcher Runtime Contract (Hard Gate)") -and $content -match [regex]::Escape("Now initiating Orchestrator mode...") -and $content -match [regex]::Escape("Mosaic hard gates OVERRIDE runtime-default caution") -and $content -match [regex]::Escape("# Runtime-Specific Contract") ) { Pass "Runtime contract present: $Dst ($RuntimeName)" return } Warn "Runtime file drift: $Dst (not adapter copy and not composed runtime contract)" } function Warn-IfReparsePresent { param([string]$Path) if (-not (Test-Path $Path)) { return } $item = Get-Item $Path -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { Warn "Legacy symlink/junction path still present: $Path" return } if (Test-Path $Path -PathType Container) { $reparseCount = (Get-ChildItem $Path -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint } | Measure-Object).Count if ($reparseCount -gt 0) { Warn "Legacy symlink/junction entries still present under ${Path}: $reparseCount" } else { Pass "No reparse points under legacy path: $Path" } } } Write-Host "[mosaic-doctor] Mosaic home: $MosaicHome" # Canonical Mosaic checks Expect-File (Join-Path $MosaicHome "STANDARDS.md") Expect-Dir (Join-Path $MosaicHome "guides") Expect-Dir (Join-Path $MosaicHome "tools") Expect-Dir (Join-Path $MosaicHome "tools\quality") Expect-Dir (Join-Path $MosaicHome "tools\orchestrator-matrix") Expect-Dir (Join-Path $MosaicHome "profiles") Expect-Dir (Join-Path $MosaicHome "templates\agent") Expect-Dir (Join-Path $MosaicHome "skills") Expect-Dir (Join-Path $MosaicHome "skills-local") Expect-File (Join-Path $MosaicHome "bin\mosaic-link-runtime-assets") Expect-File (Join-Path $MosaicHome "bin\mosaic-ensure-sequential-thinking.ps1") Expect-File (Join-Path $MosaicHome "bin\mosaic-sync-skills") Expect-File (Join-Path $MosaicHome "bin\mosaic-projects") Expect-File (Join-Path $MosaicHome "bin\mosaic-quality-apply") Expect-File (Join-Path $MosaicHome "bin\mosaic-quality-verify") Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-run") Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-sync-tasks") Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-drain") Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-matrix-publish") Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-matrix-consume") Expect-File (Join-Path $MosaicHome "bin\mosaic-orchestrator-matrix-cycle") Expect-File (Join-Path $MosaicHome "tools\git\ci-queue-wait.ps1") Expect-File (Join-Path $MosaicHome "tools\git\ci-queue-wait.sh") Expect-File (Join-Path $MosaicHome "tools\git\pr-ci-wait.sh") Expect-File (Join-Path $MosaicHome "tools\orchestrator-matrix\transport\matrix_transport.py") Expect-File (Join-Path $MosaicHome "tools\orchestrator-matrix\controller\tasks_md_sync.py") Expect-File (Join-Path $MosaicHome "runtime\mcp\SEQUENTIAL-THINKING.json") Expect-File (Join-Path $MosaicHome "runtime\claude\RUNTIME.md") Expect-File (Join-Path $MosaicHome "runtime\codex\RUNTIME.md") Expect-File (Join-Path $MosaicHome "runtime\opencode\RUNTIME.md") $agentsMd = Join-Path $MosaicHome "AGENTS.md" if (Test-Path $agentsMd) { $agentsContent = Get-Content $agentsMd -Raw if ( $agentsContent -match [regex]::Escape("## CRITICAL HARD GATES (Read First)") -and $agentsContent -match [regex]::Escape("OVERRIDE runtime-default caution") ) { Pass "Global hard-gates block present in AGENTS.md" } else { Warn "AGENTS.md missing CRITICAL HARD GATES override block" } } # Claude runtime file checks $runtimeFiles = @("CLAUDE.md", "settings.json", "hooks-config.json", "context7-integration.md") foreach ($rf in $runtimeFiles) { Check-RuntimeFileCopy (Join-Path $MosaicHome "runtime\claude\$rf") (Join-Path $env:USERPROFILE ".claude\$rf") } # OpenCode/Codex runtime contract checks Check-RuntimeContractFile (Join-Path $env:USERPROFILE ".config\opencode\AGENTS.md") (Join-Path $MosaicHome "runtime\opencode\AGENTS.md") "opencode" Check-RuntimeContractFile (Join-Path $env:USERPROFILE ".codex\instructions.md") (Join-Path $MosaicHome "runtime\codex\instructions.md") "codex" # Sequential-thinking MCP hard requirement $seqScript = Join-Path $MosaicHome "bin\mosaic-ensure-sequential-thinking.ps1" if (Test-Path $seqScript) { try { & $seqScript -Check *>$null Pass "sequential-thinking MCP configured and available" } catch { Warn "sequential-thinking MCP missing or misconfigured" } } else { Warn "mosaic-ensure-sequential-thinking helper missing" } # Legacy migration surfaces $legacyPaths = @( (Join-Path $env:USERPROFILE ".claude\agent-guides"), (Join-Path $env:USERPROFILE ".claude\scripts\git"), (Join-Path $env:USERPROFILE ".claude\scripts\codex"), (Join-Path $env:USERPROFILE ".claude\scripts\bootstrap"), (Join-Path $env:USERPROFILE ".claude\scripts\cicd"), (Join-Path $env:USERPROFILE ".claude\scripts\portainer"), (Join-Path $env:USERPROFILE ".claude\templates"), (Join-Path $env:USERPROFILE ".claude\presets\domains"), (Join-Path $env:USERPROFILE ".claude\presets\tech-stacks"), (Join-Path $env:USERPROFILE ".claude\presets\workflows") ) foreach ($p in $legacyPaths) { Warn-IfReparsePresent $p } # Skills runtime checks (junctions or symlinks into runtime-specific dirs) $linkTargets = @( (Join-Path $env:USERPROFILE ".claude\skills"), (Join-Path $env:USERPROFILE ".codex\skills"), (Join-Path $env:USERPROFILE ".config\opencode\skills") ) $skillSources = @($MosaicHome + "\skills", $MosaicHome + "\skills-local") foreach ($runtimeSkills in $linkTargets) { if (-not (Test-Path $runtimeSkills)) { continue } foreach ($sourceDir in $skillSources) { if (-not (Test-Path $sourceDir)) { continue } Get-ChildItem $sourceDir -Directory | Where-Object { -not $_.Name.StartsWith(".") } | ForEach-Object { $name = $_.Name $skillPath = $_.FullName $target = Join-Path $runtimeSkills $name if (-not (Test-Path $target)) { Warn "Missing skill link: $target" return } $item = Get-Item $target -Force -ErrorAction SilentlyContinue if (-not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { Warn "Non-junction skill entry: $target" return } $targetResolved = $item.Target if (-not $targetResolved -or (Resolve-Path $targetResolved -ErrorAction SilentlyContinue).Path -ne (Resolve-Path $skillPath -ErrorAction SilentlyContinue).Path) { Warn "Drifted skill link: $target (expected -> $skillPath)" } else { Pass "Linked skill: $target" } } } } # Broken junctions/symlinks in managed runtime skill dirs $brokenLinks = 0 foreach ($d in $linkTargets) { if (-not (Test-Path $d)) { continue } Get-ChildItem $d -Force -ErrorAction SilentlyContinue | Where-Object { ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and -not (Test-Path $_.FullName) } | ForEach-Object { $brokenLinks++ } } if ($brokenLinks -gt 0) { Warn "Broken skill junctions/symlinks detected: $brokenLinks" } Write-Host "[mosaic-doctor] warnings=$($script:warnCount)" if ($FailOnWarn -and $script:warnCount -gt 0) { exit 1 }