Payload/scripts/harness/sync-cursor-ai.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Sync Cursor AI surfaces from the canonical .github/ Copilot tree (Option A+).

.DESCRIPTION
    - Converts .github/agents/*.agent.md -> .cursor/agents/*.md
    - Copies .github/agents/knowledge/ -> .cursor/agents/knowledge/
    - Copies .github/skills/ -> .cursor/skills/
    - Converts .github/prompts/*.prompt.md -> .cursor/commands/*.md

    Run from repo root or pass -RepoRoot. Safe to re-run; overwrites generated outputs.

.EXAMPLE
    .\scripts\sync-cursor-ai.ps1
#>

[CmdletBinding()]
param(
    [string]$RepoRoot = ''
)

if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
    $RepoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
}

$ErrorActionPreference = 'Stop'

Import-Module (Join-Path $PSScriptRoot 'lib\HarnessContract.psm1') -Force

$GitHubAgents  = Join-Path $RepoRoot '.github\agents'
$GitHubSkills  = Join-Path $RepoRoot '.github\skills'
$GitHubPrompts = Join-Path $RepoRoot '.github\prompts'
$CursorAgents  = Join-Path $RepoRoot '.cursor\agents'
$CursorSkills  = Join-Path $RepoRoot '.cursor\skills'
$CursorCommands = Join-Path $RepoRoot '.cursor\commands'
$ManifestPath  = Join-Path $RepoRoot '.cursor\.sync-manifest'

$CursorDelegationBlock = @'
## Cursor Delegation

When running in Cursor, delegate to specialized agents using the **Task** tool with a prompt that names the target agent file under `.cursor/agents/`. There is no `agent/runSubagent` tool. Confirm expected output artifacts exist on disk before advancing pipeline phases. Log review iterations to `docs/harness/features/<slug>/.review-loop.json` per the Producer / Reviewer Pairing section.

'@


function Split-Frontmatter {
    param([string]$Content)
    if ($Content -match '(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$') {
        return @{ Frontmatter = $Matches[1]; Body = $Matches[2] }
    }
    return @{ Frontmatter = ''; Body = $Content }
}

function Get-FrontmatterValue {
    param([string]$Frontmatter, [string]$Key)
    if ($Frontmatter -match "(?m)^$([regex]::Escape($Key)):\s*(.+)$") {
        return $Matches[1].Trim().Trim('"').Trim("'")
    }
    return $null
}

function Test-ReadonlyAgent {
    param([string]$Name)
    return Test-HarnessCursorAgentReadonly -Name $Name
}

function Convert-HarnessPathRewrite {
    param([string]$Content)
    $result = $Content
    $result = $result -replace 'docs/wip/features/', 'docs/harness/features/'
    $result = $result -replace 'docs/wip/quality/', 'docs/harness/quality/'
    $result = $result -replace 'docs/wip/security/', 'docs/harness/security/'
    $result = $result -replace 'docs/wip/specs/', 'docs/harness/specs/'
    $result = $result -replace 'docs/wip/project-planning/', 'docs/harness/planning/'
    $result = $result -replace 'docs/wip/spec-planning/', 'docs/harness/planning/'
    $result = $result -replace 'docs/wip/product-direction/', 'docs/harness/planning/'
    $result = $result -replace 'wip-artifact-paths', 'artifact-paths'
    $result = $result -replace 'WIP Artifact Paths', 'Artifact Paths'
    $result = $result -replace 'standard WIP paths', 'canonical harness paths'
    return $result
}

function Convert-AgentBody {
    param([string]$Body, [string]$AgentName)

    $result = $Body

    $result = Convert-HarnessPathRewrite -Content $result

    # Harness artifact paths (canonical: docs/harness/features/)
    $result = $result -replace 'docs/features/', 'docs/harness/features/'

    # Host-neutral wording
    $result = $result -replace 'GitHub Copilot', 'Cursor'
    $result = $result -replace 'agent/runSubagent', 'the Task tool'
    $result = $result -replace 'VS Code Copilot', 'Cursor'
    $result = $result -replace 'via ``the Task tool``', 'via the Task tool'
    $result = $result -replace 'if ``the Task tool``', 'if the Task tool'

    # Path rewrites: agents
    $result = $result -replace '\.agent\.md', '.md'
    $result = $result -replace '\]\(\.\./agents/', '](../agents/'
    $result = $result -replace '\]\(agents/', '](../agents/'
    $result = $result -replace '\]\(\.\./\.\./agents/', '](../agents/'

    # Skills: .github/skills -> ../skills (from .cursor/agents)
    $result = $result -replace '\]\(\.\./\.\./skills/', '](../skills/'
    $result = $result -replace '\]\(\.\./skills/', '](../skills/'
    $result = $result -replace '\]\(\.github/skills/', '](../skills/'

    # Prompts -> commands
    $result = $result -replace '\.prompt\.md', '.md'
    $result = $result -replace '\]\(\.\./prompts/', '](../commands/'
    $result = $result -replace '\]\(\.\./\.\./prompts/', '](../commands/'
    $result = $result -replace '\]\(agent-audit\.prompt\.md\)', '](../commands/agent-audit.md)'
    $result = $result -replace '\]\(feature-start\.prompt\.md\)', '](../commands/feature-start.md)'
    $result = $result -replace '\]\(feature-resume\.prompt\.md\)', '](../commands/feature-resume.md)'

    $result = $result -replace '\]\(\.\./\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK'
    $result = $result -replace '\]\(\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK'
    $result = $result -replace '\]\(\.\./\.\./templates/', '](.github/templates/'
    $result = $result -replace '\]\(\.\./templates/', '](.github/templates/'
    $result = $result -replace 'TEMP_REVIEW_LOOP_LINK', '](../../templates/review-loop.json)'

    if ($AgentName -eq 'feature-orchestrator' -and $result -notmatch '## Cursor Delegation') {
        if ($result -match '(?s)^(You are the feature-orchestrator.*?\r?\n\r?\n)') {
            $result = $Matches[1] + $CursorDelegationBlock + "`n" + $result.Substring($Matches[1].Length)
        }
        else {
            $result = $CursorDelegationBlock + $result
        }
    }

    return $result
}

function Format-CursorAgentFrontmatter {
    param(
        [string]$SourceFrontmatter,
        [string]$Name
    )

    $description = Get-FrontmatterValue -Frontmatter $SourceFrontmatter -Key 'description'
    if (-not $description) {
        throw "Agent '$Name' is missing required description frontmatter."
    }

    $readonly = Test-ReadonlyAgent -Name $Name
    $model = if ($readonly) { 'fast' } else { 'inherit' }

    $lines = @(
        '---',
        "name: $Name",
        "description: $description",
        "model: $model",
        "readonly: $($readonly.ToString().ToLower())",
        'is_background: false',
        '---'
    )
    return ($lines -join "`n")
}

function Write-FileIfContentChanged {
    param(
        [string]$Path,
        [string]$Content
    )

    if (Test-Path $Path) {
        $existing = [System.IO.File]::ReadAllText($Path)
        if (($existing -replace "`r`n", "`n") -eq ($Content -replace "`r`n", "`n")) {
            return $false
        }
    }

    $parent = Split-Path $Path -Parent
    if (-not (Test-Path $parent)) {
        New-Item -ItemType Directory -Path $parent -Force | Out-Null
    }

    $utf8NoBom = New-Object System.Text.UTF8Encoding $false
    [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
    return $true
}

function Convert-AgentFile {
    param([string]$SourcePath, [string]$DestPath)

    $raw = Get-Content -Path $SourcePath -Raw -Encoding UTF8
    $parts = Split-Frontmatter -Content $raw
    $name = Get-FrontmatterValue -Frontmatter $parts.Frontmatter -Key 'name'
    if (-not $name) {
        $name = [System.IO.Path]::GetFileNameWithoutExtension($SourcePath) -replace '\.agent$',''
    }

    $fm = Format-CursorAgentFrontmatter -SourceFrontmatter $parts.Frontmatter -Name $name
    $body = Convert-AgentBody -Body $parts.Body -AgentName $name
    $out = $fm + "`n" + $body

    Write-FileIfContentChanged -Path $DestPath -Content $out | Out-Null
}

function Convert-SkillContent {
    param([string]$Content)

    $result = $Content
    $result = $result -replace '\]\(\.\./\.\./prompts/([^)#]+)\.prompt\.md', '](../../commands/$1.md'
    $result = $result -replace '\]\(\.\./\.\./agents/([^)]+)\.agent\.md', '](../../agents/$1.md'
    $result = Convert-HarnessPathRewrite -Content $result
    $result = $result -replace '\]\(\.\./\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK'
    $result = $result -replace '\]\(\.\./\.\./templates/', '](.github/templates/'
    $result = $result -replace 'TEMP_REVIEW_LOOP_LINK', '](../../templates/review-loop.json)'
    return $result
}

function Convert-AgentKnowledgeContent {
    param([string]$Content)

    $result = $Content
    # knowledge/*.md lives at .cursor/agents/knowledge/ - skills at ../../skills/
    $result = $result -replace '\]\(\.\./\.\./skills/', '](../../skills/'
    $result = $result -replace '\]\(\.\./skills/', '](../../skills/'
    return $result
}

function Copy-AgentKnowledgeTree {
    param(
        [string]$Source,
        [string]$Destination
    )

    if (-not (Test-Path $Source)) {
        return 0
    }

    if (-not (Test-Path $Destination)) {
        New-Item -ItemType Directory -Path $Destination -Force | Out-Null
    }

    $fileCount = 0
    Get-ChildItem -Path $Source -Recurse -File -Force | ForEach-Object {
        $relative = $_.FullName.Substring($Source.Length).TrimStart('\')
        $target = Join-Path $Destination $relative
        $parent = Split-Path $target -Parent
        if (-not (Test-Path $parent)) {
            New-Item -ItemType Directory -Path $parent -Force | Out-Null
        }
        if ($_.Extension -eq '.md') {
            $raw = Get-Content -Path $_.FullName -Raw -Encoding UTF8
            $converted = Convert-AgentKnowledgeContent -Content $raw
            Write-FileIfContentChanged -Path $target -Content $converted | Out-Null
        }
        else {
            Copy-Item -Path $_.FullName -Destination $target -Force
        }
        $fileCount++
    }
    return $fileCount
}

function Copy-SkillsTree {
    param([string]$Source, [string]$Destination)

    if (-not (Test-Path $Destination)) {
        New-Item -ItemType Directory -Path $Destination -Force | Out-Null
    }

    $excludeDirs = @('__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', 'node_modules')
    Get-ChildItem -Path $Source -Recurse -Force | ForEach-Object {
        $relative = $_.FullName.Substring($Source.Length).TrimStart('\')
        foreach ($dir in $excludeDirs) {
            if ($relative -match "(^|[\\/])$([regex]::Escape($dir))([\\/]|$)") {
                return
            }
        }
        $target = Join-Path $Destination $relative
        if ($_.PSIsContainer) {
            if (-not (Test-Path $target)) {
                New-Item -ItemType Directory -Path $target -Force | Out-Null
            }
        }
        else {
            $parent = Split-Path $target -Parent
            if (-not (Test-Path $parent)) {
                New-Item -ItemType Directory -Path $parent -Force | Out-Null
            }
            if ($_.Extension -eq '.md') {
                $raw = Get-Content -Path $_.FullName -Raw -Encoding UTF8
                $converted = Convert-SkillContent -Content $raw
                Write-FileIfContentChanged -Path $target -Content $converted | Out-Null
            }
            else {
                Copy-Item -Path $_.FullName -Destination $target -Force
            }
        }
    }
}

function Convert-PromptFile {
    param([string]$SourcePath, [string]$DestPath)

    $raw = Get-Content -Path $SourcePath -Raw -Encoding UTF8
    $parts = Split-Frontmatter -Content $raw
    $description = Get-FrontmatterValue -Frontmatter $parts.Frontmatter -Key 'description'
    if (-not $description) {
        $description = 'Cursor command workflow'
    }

    $body = $parts.Body
    $body = Convert-HarnessPathRewrite -Content $body
    $body = $body -replace 'docs/features/', 'docs/harness/features/'
    $body = $body -replace '\.agent\.md', '.md'
    $body = $body -replace '\]\(\.\./agents/', '](../agents/'
    $body = $body -replace '\]\(\.\./skills/', '](../skills/'
    $body = $body -replace '\]\(\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK'
    $body = $body -replace '\]\(\.\./templates/', '](.github/templates/'
    $body = $body -replace 'TEMP_REVIEW_LOOP_LINK', '](../../templates/review-loop.json)'
    $body = $body -replace '\.prompt\.md', '.md'
    $body = $body -replace '\]\(\.\./prompts/', '](../commands/'
    $body = $body -replace 'feature-resume\.prompt\.md', 'feature-resume.md'
    $body = $body -replace 'feature-start\.prompt\.md', 'feature-start.md'
    $body = $body -replace 'agent-audit\.prompt\.md', 'agent-audit.md'
    $body = $body -replace 'Invoke the \[`([^`]+)`\]\(\.\./agents/([^)]+)\) agent', 'Delegate to the [$1](../agents/$2) agent using the Task tool'
    $body = $body -replace 'Invoke the \[([^\]]+)\]\(\.\./agents/([^)]+)\) agent', 'Delegate to the [$1](../agents/$2) agent using the Task tool'

    $cursorNote = @'

## Cursor Notes

- Entry point: type `/` and select this command, or @-mention `.cursor/commands/$(Split-Path -Leaf $DestPath)`.
- Delegate to agents via the **Task** tool; reference `.cursor/agents/<name>.md` in the delegation prompt.

'@


    $fm = @(
        '---',
        "description: $description",
        '---'
    ) -join "`n"
    $out = $fm + "`n" + $body + $cursorNote

    Write-FileIfContentChanged -Path $DestPath -Content $out | Out-Null
}

# --- Main ---
@($CursorAgents, $CursorSkills, $CursorCommands) | ForEach-Object {
    if (-not (Test-Path $_)) {
        New-Item -ItemType Directory -Path $_ -Force | Out-Null
    }
}

$agentCount = 0
Get-ChildItem -Path $GitHubAgents -Filter '*.agent.md' -File | ForEach-Object {
    $destName = $_.Name -replace '\.agent\.md$','.md'
    $dest = Join-Path $CursorAgents $destName
    Convert-AgentFile -SourcePath $_.FullName -DestPath $dest
    $agentCount++
}

$knowledgeSource = Join-Path $GitHubAgents 'knowledge'
$knowledgeDest = Join-Path $CursorAgents 'knowledge'
$knowledgeCount = Copy-AgentKnowledgeTree -Source $knowledgeSource -Destination $knowledgeDest

Copy-SkillsTree -Source $GitHubSkills -Destination $CursorSkills

$commandCount = 0
Get-ChildItem -Path $GitHubPrompts -Filter '*.prompt.md' -File | ForEach-Object {
    $destName = $_.Name -replace '\.prompt\.md$','.md'
    $dest = Join-Path $CursorCommands $destName
    Convert-PromptFile -SourcePath $_.FullName -DestPath $dest
    $commandCount++
}

$manifest = @{
    synced_at_utc = (Get-Date).ToUniversalTime().ToString('o')
    source        = '.github/'
    targets       = @('.cursor/agents', '.cursor/agents/knowledge', '.cursor/skills', '.cursor/commands')
    agents        = $agentCount
    knowledge     = $knowledgeCount
    commands      = $commandCount
    script        = 'scripts/sync-cursor-ai.ps1'
} | ConvertTo-Json -Depth 3

$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($ManifestPath, $manifest, $utf8NoBom)

Write-Output "Sync complete: $agentCount agents, $knowledgeCount knowledge file(s), $commandCount commands, skills tree copied."
Write-Output "Manifest: $ManifestPath"