param( [int]$IntervalSeconds = 30, [int]$HeartbeatEverySeconds = 60, [switch]$Once ) $ErrorActionPreference = "Stop" $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $LocalStateDir = Join-Path $RepoRoot ".sync-state" $PublicStateDir = Join-Path $RepoRoot "sync-state" $HeartbeatFile = Join-Path $PublicStateDir "heartbeat-pc.json" $ErrorFile = Join-Path $PublicStateDir "error-pc.md" $AllowedPaths = @( "AGENT_BOARD.md", "README.md", "USER_STATUS.md", ".cursor/rules", "handoff", "evidence", "rollback", "tasks", "sync", "sync-state", "docs", "knowledge", "archive", ".gitattributes", ".gitignore" ) function Ensure-StateDir { foreach ($path in @($LocalStateDir, $PublicStateDir)) { if (-not (Test-Path $path)) { New-Item -ItemType Directory -Path $path | Out-Null } } } function Write-SyncError { param([string]$Message) Ensure-StateDir $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz" @" # PC Sync Error - Time: $timestamp - Repository: $RepoRoot - Message: $Message Manual action required. Do not continue automatic sync until the repository is clean. "@ | Set-Content -Path $ErrorFile -Encoding UTF8 } function Get-GitOutput { param([string[]]$GitArgs) $output = & git @GitArgs if ($LASTEXITCODE -ne 0) { throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE" } return $output } function Invoke-Git { param([string[]]$GitArgs) & git @GitArgs if ($LASTEXITCODE -ne 0) { throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE" } } function Remove-StaleSyncError { if (Test-Path $ErrorFile) { Remove-Item $ErrorFile -Force } } function Push-WithRetry { & git push if ($LASTEXITCODE -eq 0) { return } # Another agent may have pushed between our pull and push. Rebase once and retry. Invoke-Git @("pull", "--rebase", "--autostash") if (Test-ConflictMarkers) { throw "merge or rebase conflicts detected after push retry" } Invoke-Git @("push") } function Get-AllowedChanges { $changes = & git -C $RepoRoot status --porcelain if ($LASTEXITCODE -ne 0) { throw "git status failed with exit code $LASTEXITCODE" } return $changes | Where-Object { $line = $_ if ([string]::IsNullOrWhiteSpace($line)) { return $false } $path = $line.Substring(3).Trim() foreach ($allowed in $AllowedPaths) { if ($path -eq $allowed -or $path.StartsWith("$allowed/") -or $path.StartsWith("$allowed\")) { return $true } } return $false } } function Test-ConflictMarkers { $conflicts = & git -C $RepoRoot diff --name-only --diff-filter=U if ($LASTEXITCODE -ne 0) { throw "git diff conflict check failed with exit code $LASTEXITCODE" } return -not [string]::IsNullOrWhiteSpace(($conflicts -join "")) } function Get-RepoCleanStatus { $changes = & git -C $RepoRoot status --porcelain if ($LASTEXITCODE -ne 0) { throw "git status failed with exit code $LASTEXITCODE" } if ([string]::IsNullOrWhiteSpace(($changes -join ""))) { return "clean" } return "dirty" } function Test-HeartbeatDue { if ($Once -or -not (Test-Path $HeartbeatFile)) { return $true } $age = (Get-Date) - (Get-Item $HeartbeatFile).LastWriteTime return $age.TotalSeconds -ge $HeartbeatEverySeconds } function Write-Heartbeat { Ensure-StateDir if (-not (Test-HeartbeatDue)) { return } $head = (Get-GitOutput @("-C", $RepoRoot, "rev-parse", "--short", "HEAD")) -join "" $branch = (Get-GitOutput @("-C", $RepoRoot, "branch", "--show-current")) -join "" $status = Get-RepoCleanStatus $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz" [ordered]@{ host = "pc" time = $timestamp branch = $branch head = $head status = $status intervalSeconds = $IntervalSeconds heartbeatEverySeconds = $HeartbeatEverySeconds } | ConvertTo-Json | Set-Content -Path $HeartbeatFile -Encoding UTF8 } function Sync-Once { Set-Location $RepoRoot Ensure-StateDir Invoke-Git @("pull", "--rebase", "--autostash") if (Test-ConflictMarkers) { throw "merge or rebase conflicts detected" } Remove-StaleSyncError Write-Heartbeat $allowedChanges = @(Get-AllowedChanges) if ($allowedChanges.Count -eq 0) { return } foreach ($path in $AllowedPaths) { if (Test-Path (Join-Path $RepoRoot $path)) { Invoke-Git @("add", "--", $path) } } $staged = & git -C $RepoRoot diff --cached --name-only if ($LASTEXITCODE -ne 0) { throw "git diff --cached failed with exit code $LASTEXITCODE" } if ([string]::IsNullOrWhiteSpace(($staged -join ""))) { return } $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Invoke-Git @("commit", "-m", "Sync agent state from PC at $timestamp") Push-WithRetry } do { try { Sync-Once } catch { Write-SyncError $_.Exception.Message Write-Error $_ break } if ($Once) { break } Start-Sleep -Seconds $IntervalSeconds } while ($true)