284 lines
10 KiB
PowerShell
284 lines
10 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 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 "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-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 "rails\git\ci-queue-wait.ps1")
|
|
Expect-File (Join-Path $MosaicHome "rails\git\ci-queue-wait.sh")
|
|
Expect-File (Join-Path $MosaicHome "rails\git\pr-ci-wait.sh")
|
|
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")
|
|
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
|
|
}
|