Payload/scripts/kaden/harness/harness-persist-quality.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Persist quality-gate reports under docs/harness/features/<slug>/quality/.

.DESCRIPTION
    Writes security / consistency / documentation report files from content or paths.
    Rejects stub reports (under MinBytes 2000, or missing a findings/score section)
    so agents cannot satisfy gates with empty shells. Persist specialist markdown verbatim.
    If extraction finds ids, the specialist findings sibling must already exist and not be empty;
    persist registers that file and does not backfill from markdown. Tests may pass -AllowStub.

.EXAMPLE
    pwsh -File scripts/kaden/harness-invoke.ps1 harness-persist-quality.ps1 -Feature saved-search-queries -Slice mo-save-list -SecurityPath .\tmp\sec.md -ConsistencyPath .\tmp\cons.md -DocumentationPath .\tmp\docs.md
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$Feature,

    [string]$Slice = '',

    [string]$FeatureRoot = '',

    [string]$RepoRoot = '',

    [string]$SecurityPath = '',
    [string]$ConsistencyPath = '',
    [string]$DocumentationPath = '',
    [string]$SmellPath = '',
    [string]$TestDesignPath = '',

    [string]$SecurityContent = '',
    [string]$ConsistencyContent = '',
    [string]$DocumentationContent = '',
    [string]$SmellContent = '',
    [string]$TestDesignContent = '',

    [int]$MinBytes = 2000,

    # Immutable host dispatch id when the host exposes one (Cursor Task). Copilot does not expose it.
    [string]$SourceInvocationId = '',

    [ValidateSet('host-id', 'artifact-attested')]
    [string]$SourceProvenance = 'host-id',

    # When omitted, inherit from the open-turn ActivityStarted model.
    [ValidateSet('cursor', 'copilot', 'other', '')]
    [string]$ModelProvider = '',

    [string]$ModelSlug = '',

    [ValidateSet('primary', 'subagent')]
    [string]$ModelRole = 'primary',

    [switch]$AllowStub,

    # Tests only: allow ModelProvider other without an open-turn inherit.
    [switch]$AllowUnknownActor,

    [switch]$AllowArbitraryFeatureRoot
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module (Join-Path (Join-Path $PSScriptRoot 'lib') 'HarnessContract.psm1') -Force
Import-Module (Join-Path (Join-Path $PSScriptRoot 'lib') 'HarnessJournal.psm1') -Force
Test-HarnessContractModuleReady

if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $RepoRoot = Get-HarnessRepoRoot -StartPath $PSScriptRoot
}
if ([string]::IsNullOrWhiteSpace($Slice)) {
    throw 'harness-persist-quality.ps1 requires -Slice for feature-scoped quality reports.'
}

$featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature `
    -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot

$hasAnyReport = @(
    @(
        $SecurityPath, $ConsistencyPath, $DocumentationPath, $SmellPath, $TestDesignPath,
        $SecurityContent, $ConsistencyContent, $DocumentationContent, $SmellContent, $TestDesignContent
    ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if ($hasAnyReport.Count -gt 0 -and -not $AllowStub) {
    if ($SourceProvenance -eq 'host-id' -and [string]::IsNullOrWhiteSpace($SourceInvocationId)) {
        throw 'persist-quality with SourceProvenance host-id requires -SourceInvocationId.'
    }
    if ($SourceProvenance -eq 'artifact-attested' -and ($ModelProvider -ne 'copilot' -or -not [string]::IsNullOrWhiteSpace($SourceInvocationId))) {
        throw 'SourceProvenance artifact-attested is only valid for Copilot subagents without a host invocation id.'
    }
}

$resolvedProvider = $ModelProvider
$resolvedSlug = $ModelSlug
if ($ModelProvider -eq 'other' -and -not $AllowUnknownActor -and -not $AllowStub) {
    throw 'persist-quality refuses ModelProvider other without -AllowUnknownActor; omit -ModelProvider to inherit the open-turn model.'
}
if ([string]::IsNullOrWhiteSpace($resolvedProvider)) {
    $inherited = Get-HarnessOpenTurnModel -FeatureRoot $featureRootPath -SliceId $Slice
    if ($inherited -and -not [string]::IsNullOrWhiteSpace([string]$inherited.provider)) {
        $resolvedProvider = [string]$inherited.provider
        if ([string]::IsNullOrWhiteSpace($resolvedSlug) -or $resolvedSlug -eq 'unknown') {
            $resolvedSlug = [string]$inherited.slug
        }
    }
}
if ([string]::IsNullOrWhiteSpace($resolvedProvider)) {
    $resolvedProvider = 'other'
}
if ([string]::IsNullOrWhiteSpace($resolvedSlug)) {
    $resolvedSlug = 'unknown'
}
if ($resolvedProvider -eq 'other' -and -not $AllowUnknownActor -and -not $AllowStub) {
    throw 'persist-quality has no open-turn model to inherit; pass -ModelProvider cursor|copilot or -AllowUnknownActor (tests).'
}

$qualityDir = Join-Path $featureRootPath 'quality'
New-Item -ItemType Directory -Path $qualityDir -Force | Out-Null

$canonical = Get-HarnessCanonicalQualityArtifactNames -SliceId $Slice

function Get-HarnessQualityBody {
    param([string]$Path, [string]$Content, [string]$Label, [switch]$AllowStubBody)
    $body = ''
    if (-not [string]::IsNullOrWhiteSpace($Content)) {
        $body = $Content
    }
    elseif (-not [string]::IsNullOrWhiteSpace($Path)) {
        Assert-HarnessPipelineSourceNotScratch -Path $Path -RepoRoot $RepoRoot
        if (-not (Test-Path -LiteralPath $Path)) {
            throw "Quality report path not found ($Label): $Path"
        }
        $body = Get-Content -LiteralPath $Path -Raw
    }
    else {
        throw ("Provide -{0}Path or -{1}Content" -f $Label, $Label)
    }
    $bytes = [System.Text.Encoding]::UTF8.GetByteCount($body)
    if (-not $AllowStubBody -and $bytes -lt $MinBytes) {
        throw "Quality report '$Label' is only $bytes bytes (min $MinBytes). Persist the full subagent report, not a stub."
    }
    if ($body -notmatch '(?i)verdict|PASS|FAIL|APPROVE|NEEDS_REVISION') {
        throw "Quality report '$Label' must include a verdict keyword (PASS/FAIL/APPROVE/...)."
    }
    if (-not $AllowStubBody -and -not (Test-HarnessQualityReportHasSpecialistStructure -Body $body)) {
        throw "Quality report '$Label' must include a findings or score section. Persist the specialist report verbatim; do not hand-summarize."
    }
    return $body
}

function Remove-HarnessQualityStagingIncomingFile {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness cleanup - agents must not prompt.')]
    param([string]$SourcePath, [string]$FeatureRootPath)
    if ([string]::IsNullOrWhiteSpace($SourcePath)) { return }
    $full = [System.IO.Path]::GetFullPath($SourcePath)
    $qualityFull = [System.IO.Path]::GetFullPath((Join-Path $FeatureRootPath 'quality'))
    if (-not (Test-HarnessPathContainedUnder -Root $qualityFull -CandidatePath $full)) {
        return
    }
    $leaf = Split-Path $full -Leaf
    if ($leaf -like '_incoming*') {
        Remove-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
    }
}

$written = [ordered]@{}
$pairs = @(
    @{ Label = 'Security'; Rel = $canonical.Security; Path = $SecurityPath; Content = $SecurityContent; SourcePath = $SecurityPath; Agent = 'security-assessor' }
    @{ Label = 'Consistency'; Rel = $canonical.Consistency; Path = $ConsistencyPath; Content = $ConsistencyContent; SourcePath = $ConsistencyPath; Agent = 'consistency-checker' }
    @{ Label = 'Documentation'; Rel = $canonical.Documentation; Path = $DocumentationPath; Content = $DocumentationContent; SourcePath = $DocumentationPath; Agent = 'documentation-reviewer' }
    @{ Label = 'Smell'; Rel = $canonical.Smell; Path = $SmellPath; Content = $SmellContent; SourcePath = $SmellPath; Agent = 'code-smell-detector' }
    @{ Label = 'TestDesign'; Rel = $canonical.TestDesign; Path = $TestDesignPath; Content = $TestDesignContent; SourcePath = $TestDesignPath; Agent = 'test-design-reviewer' }
)

foreach ($p in $pairs) {
    if ([string]::IsNullOrWhiteSpace($p.Path) -and [string]::IsNullOrWhiteSpace($p.Content)) {
        continue
    }
    if (-not [string]::IsNullOrWhiteSpace($p.Path)) {
        Assert-HarnessPipelineSourceNotScratch -Path $p.Path -RepoRoot $RepoRoot
    }
    $destFull = Join-HarnessPath -Root $featureRootPath -Child $p.Rel
    $contentOnly = (-not [string]::IsNullOrWhiteSpace($p.Content)) -and [string]::IsNullOrWhiteSpace($p.Path)
    if ($contentOnly) {
        Assert-HarnessCanonicalQualityReportOnDisk -FeatureRoot $featureRootPath -RelativePath $p.Rel -AllowStub:$AllowStub
    }
    if (-not $AllowStub -and $contentOnly -and (Test-Path -LiteralPath $destFull)) {
        $body = Get-Content -LiteralPath $destFull -Raw
        $normIn = ($p.Content -replace '\r\n', "`n").Trim()
        $normDisk = ($body -replace '\r\n', "`n").Trim()
        if ($normIn -ne $normDisk) {
            throw ("Quality report '{0}' Content does not match on-disk allowlist file {1}" -f $p.Label, $p.Rel)
        }
        $bytes = [System.Text.Encoding]::UTF8.GetByteCount($body)
        if ($bytes -lt $MinBytes) {
            throw "Quality report '$($p.Label)' is only $bytes bytes (min $MinBytes). Persist the full subagent report, not a stub."
        }
        if ($body -notmatch '(?i)verdict|PASS|FAIL|APPROVE|NEEDS_REVISION') {
            throw "Quality report '$($p.Label)' must include a verdict keyword (PASS/FAIL/APPROVE/...)."
        }
        if (-not (Test-HarnessQualityReportHasSpecialistStructure -Body $body)) {
            throw "Quality report '$($p.Label)' must include a findings or score section. Persist the specialist report verbatim; do not hand-summarize."
        }
    }
    elseif (-not $AllowStub -and -not $contentOnly -and (Test-Path -LiteralPath $destFull) -and -not [string]::IsNullOrWhiteSpace($p.Path)) {
        $pathFull = [System.IO.Path]::GetFullPath($p.Path)
        if ([string]::Equals($pathFull, [System.IO.Path]::GetFullPath($destFull), [StringComparison]::OrdinalIgnoreCase)) {
            $body = Get-Content -LiteralPath $destFull -Raw
            $bytes = [System.Text.Encoding]::UTF8.GetByteCount($body)
            if ($bytes -lt $MinBytes) {
                throw "Quality report '$($p.Label)' is only $bytes bytes (min $MinBytes). Persist the full subagent report, not a stub."
            }
            if ($body -notmatch '(?i)verdict|PASS|FAIL|APPROVE|NEEDS_REVISION') {
                throw "Quality report '$($p.Label)' must include a verdict keyword (PASS/FAIL/APPROVE/...)."
            }
            if (-not (Test-HarnessQualityReportHasSpecialistStructure -Body $body)) {
                throw "Quality report '$($p.Label)' must include a findings or score section. Persist the specialist report verbatim; do not hand-summarize."
            }
        }
        else {
            $body = Get-HarnessQualityBody -Path $p.Path -Content $p.Content -Label $p.Label -AllowStubBody:$AllowStub
        }
    }
    else {
        $body = Get-HarnessQualityBody -Path $p.Path -Content $p.Content -Label $p.Label -AllowStubBody:$AllowStub
    }
    if ($p.Label -eq 'Security' -and -not (Test-HarnessSecurityReportScannerHonesty -ReportBody $body -RepoRoot $RepoRoot)) {
        throw "Security report appears score-100 clean while scanners were skipped on changed path classes. Add informational findings with disposition (skipped-not-in-scope) before persist."
    }
    $extracted = @(Get-HarnessQualityMarkdownFinding -Body $body)
    if (-not $AllowStub) {
        if ((Test-HarnessQualityMarkdownFindingsTableHasCandidateRows -Body $body) -and $extracted.Count -eq 0) {
            throw ("Quality report '{0}' has a Findings table with data rows but no findings were extracted. Use parseable ID and Severity columns or ATX headings; do not hand-summarize." -f $p.Label)
        }
    }
    $full = $destFull
    New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null
    $alreadyCanonical = (-not $AllowStub) -and (Test-Path -LiteralPath $full) -and (
        $contentOnly -or (
            -not [string]::IsNullOrWhiteSpace($p.Path) -and
            [string]::Equals([System.IO.Path]::GetFullPath($p.Path), [System.IO.Path]::GetFullPath($full), [StringComparison]::OrdinalIgnoreCase)
        )
    )
    if (-not $alreadyCanonical) {
        [System.IO.File]::WriteAllText($full, $body, [System.Text.UTF8Encoding]::new($false))
    }
    $written[$p.Label] = $p.Rel
    $stem = [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Path ($p.Rel -replace '\\', '/') -Leaf))
    $dataRel = 'quality/' + $stem + '-data.json'
    $dataFull = Join-HarnessPath -Root $featureRootPath -Child $dataRel
    $specialistFindings = @()
    if (Test-Path -LiteralPath $dataFull) {
        $dataExisting = ConvertFrom-HarnessJson -Json (Get-Content -LiteralPath $dataFull -Raw)
        $specialistFindings = @((Get-HarnessProperty -Object $dataExisting -Name 'findings' -Default @()) | Where-Object { $null -ne $_ })
    }
    if (-not $AllowStub -and $extracted.Count -gt 0 -and $specialistFindings.Count -eq 0) {
        throw ("Quality report '{0}' names findings but the findings file is missing or findings is empty. Specialist must write {1}; persist does not backfill." -f $p.Label, $dataRel)
    }
    if ($specialistFindings.Count -eq 0) {
        # Empty-report path, or -AllowStub. Persist does not backfill a named-findings report.
        $findingRows = [System.Collections.ArrayList]@()
        foreach ($f in $extracted) {
            [void]$findingRows.Add([ordered]@{
                    id            = [string]$f.id
                    severity      = [string]$f.severity
                    location      = [string]$f.location
                    message       = [string]$f.message
                    suggested_fix = [string]$f.suggested_fix
                })
        }
        $dataDoc = [ordered]@{
            agent    = [string]$p.Agent
            findings = @($findingRows.ToArray())
        }
        [System.IO.File]::WriteAllText($dataFull, ($dataDoc | ConvertTo-Json -Depth 6), [System.Text.UTF8Encoding]::new($false))
    }
    Remove-HarnessQualityStagingIncomingFile -SourcePath $p.SourcePath -FeatureRootPath $featureRootPath
    Remove-HarnessStagedPayloadIfEligible -CandidatePath $p.SourcePath -RepoRoot $RepoRoot -FeatureRoot $featureRootPath
    $actor = New-HarnessJournalActor -Agent 'harness-persist-quality.ps1' -ParentAgent $null `
        -Tool 'harness-script' -Provider $resolvedProvider -Slug $resolvedSlug -Role $ModelRole
    $activity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'artifact' -Name 'persist-quality' -Role 'instant'
    Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $(if ($Slice) { $Slice } else { $null }) `
        -Kind 'ArtifactRegistered' -Activity $activity -Actor $actor -Outcome 'PASS' `
        -Summary "quality report landed $($p.Rel)" `
        -Payload ([pscustomobject]@{
            gate                  = $p.Label.ToLowerInvariant()
            artifact              = $p.Rel
            bytes                 = [System.Text.Encoding]::UTF8.GetByteCount($body)
            source_provenance     = $SourceProvenance
            source_invocation_id  = $(if ($SourceInvocationId) { $SourceInvocationId } else { $null })
        }) `
        -Refs ([pscustomobject]@{
            amends_event_id = $null
            pair_event_id   = $null
            artifact        = $p.Rel
            command         = '.\scripts\kaden\harness-persist-quality.ps1'
        }) | Out-Null
}

$canonicalPaths = [ordered]@{}
foreach ($label in @($written.Keys)) {
    $rel = [string]$written[$label]
    $canonicalPaths[$label] = Join-HarnessPath -Root $featureRootPath -Child $rel
}

[pscustomobject]@{
    feature         = $Feature
    slice           = $Slice
    written         = $written
    canonical_paths = $canonicalPaths
    minBytes        = $MinBytes
} | ConvertTo-Json -Depth 5