Files
bootstrap/bin/mosaic-doctor.ps1
Jason Woltje 7316870c81 feat: add Windows PowerShell support and remote install one-liners
- remote-install.sh: POSIX one-liner (curl | sh), downloads archive to tmpdir
- remote-install.ps1: Windows one-liner (irm | iex), fully native PowerShell
- install.ps1: Native Windows installer calling all .ps1 post-install scripts
- bin/mosaic-link-runtime-assets.ps1: Syncs runtime config files
- bin/mosaic-sync-skills.ps1: Clones skills, links via directory junctions
- bin/mosaic-migrate-local-skills.ps1: Migrates local skills to junctions
- bin/mosaic-doctor.ps1: Health audit for Windows environments

Directory junctions used instead of symlinks (no elevation required).
All junction operations fall back to file copy on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:49:36 -06:00

207 lines
7.3 KiB
PowerShell

# 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 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 "rails")
Expect-Dir (Join-Path $MosaicHome "rails\quality")
Expect-Dir (Join-Path $MosaicHome "rails\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-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 "rails\orchestrator-matrix\transport\matrix_transport.py")
Expect-File (Join-Path $MosaicHome "rails\orchestrator-matrix\controller\tasks_md_sync.py")
# 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 runtime adapter
Check-RuntimeFileCopy (Join-Path $MosaicHome "runtime\opencode\AGENTS.md") (Join-Path $env:USERPROFILE ".config\opencode\AGENTS.md")
# 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
}