skills/subagent-dispatch/scripts/HandoffSequenceCommon.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
    Shared readers for the handoff sequence scripts.
.DESCRIPTION
    Dot-sourced by Test-HandoffSequence.ps1 and Get-HandoffSequenceInventory.ps1.
    Reads Markdown text, front matter, tables, sections, links, and the slots a
    brief has to carry. It defines functions only and changes nothing.
#>


function Read-HandoffText {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    # A byte order mark is detected and dropped, so the first line is the first line.
    [IO.File]::ReadAllText($Path, [Text.UTF8Encoding]::new($false))
}

function Test-HandoffReparsePoint {
    <#
    .SYNOPSIS
        Tells whether a file or folder is a link, which these scripts never read through.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [IO.FileSystemInfo] $Item
    )

    ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0
}

function Get-HandoffFrontMatter {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $result = [pscustomobject]@{
        IsValid = $false
        Value   = @{}
        Body    = $Text
    }

    $line = @($Text -split '\r?\n')
    if ($line.Count -lt 2 -or $line[0].TrimEnd() -ne '---') {
        return $result
    }

    $end = -1
    for ($index = 1; $index -lt $line.Count; $index++) {
        if ($line[$index].TrimEnd() -eq '---') {
            $end = $index
            break
        }
    }

    if ($end -lt 0) {
        return $result
    }

    $value = @{}
    $key = $null
    for ($index = 1; $index -lt $end; $index++) {
        $current = $line[$index]
        if ($current -match '^(?<key>[A-Za-z][A-Za-z0-9_-]*):\s*(?<value>.*)$') {
            $key = $Matches['key']
            $scalar = $Matches['value'].Trim()
            if ($scalar -in @('>', '>-', '|', '|-')) {
                $scalar = ''
            } elseif ($scalar.Length -ge 2 -and (
                    ($scalar.StartsWith('"') -and $scalar.EndsWith('"')) -or
                    ($scalar.StartsWith("'") -and $scalar.EndsWith("'")))) {
                $scalar = $scalar.Substring(1, $scalar.Length - 2)
            }

            $value[$key] = $scalar
        } elseif ($null -ne $key -and $current -match '^\s+\S') {
            $value[$key] = ($value[$key] + ' ' + $current.Trim()).Trim()
        }
    }

    $result.IsValid = $true
    $result.Value = $value
    $result.Body = if ($end + 1 -lt $line.Count) { $line[($end + 1)..($line.Count - 1)] -join "`n" } else { '' }
    $result
}

function ConvertFrom-HandoffTableRow {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $inner = $Text.Trim()
    if ($inner.StartsWith('|')) {
        $inner = $inner.Substring(1)
    }

    if ($inner.EndsWith('|') -and -not $inner.EndsWith('\|')) {
        $inner = $inner.Substring(0, $inner.Length - 1)
    }

    foreach ($part in [regex]::Split($inner, '(?<!\\)\|')) {
        $part.Replace('\|', '|').Trim()
    }
}

function Get-HandoffTable {
    <#
    .SYNOPSIS
        Returns every pipe table in the lines, ignoring fenced code.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string[]] $Line
    )

    $table = [Collections.Generic.List[object]]::new()
    $delimiter = '^\s*\|(?:\s*:?-+:?\s*\|)+\s*$'
    $inFence = $false
    $index = 0
    while ($index -lt $Line.Count) {
        $current = $Line[$index]
        if ($current -match '^\s*(```|~~~)') {
            $inFence = -not $inFence
            $index++
            continue
        }

        if (-not $inFence -and $current -match '^\s*\|' -and ($index + 1) -lt $Line.Count -and $Line[$index + 1] -match $delimiter) {
            $header = @(ConvertFrom-HandoffTableRow -Text $current)
            $row = [Collections.Generic.List[object]]::new()
            $start = $index
            $index += 2
            while ($index -lt $Line.Count -and $Line[$index] -match '^\s*\|') {
                $row.Add([pscustomobject]@{
                        Line = $index + 1
                        Cell = @(ConvertFrom-HandoffTableRow -Text $Line[$index])
                    })
                $index++
            }

            $table.Add([pscustomobject]@{
                    Line   = $start + 1
                    Header = $header
                    Row    = $row.ToArray()
                })
            continue
        }

        $index++
    }

    $table.ToArray()
}

function Get-HandoffColumnIndex {
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]] $Header,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Pattern
    )

    for ($index = 0; $index -lt $Header.Count; $index++) {
        if ($Header[$index] -match $Pattern) {
            return $index
        }
    }

    -1
}

function Get-HandoffSection {
    <#
    .SYNOPSIS
        Returns the text under the first heading that matches, up to the next heading of the same or a higher level.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $HeadingPattern
    )

    $line = @($Text -split '\r?\n')
    $inFence = $false
    $start = -1
    $level = 0
    for ($index = 0; $index -lt $line.Count; $index++) {
        if ($line[$index] -match '^\s*(```|~~~)') {
            $inFence = -not $inFence
            continue
        }

        if ($inFence) {
            continue
        }

        if ($start -lt 0) {
            if ($line[$index] -match ('^(?<hashes>#{1,6})\s+(?:' + $HeadingPattern + ')\b[^\r\n]*$')) {
                $start = $index + 1
                $level = $Matches['hashes'].Length
            }
        } elseif ($line[$index] -match '^(?<hashes>#{1,6})\s+\S' -and $Matches['hashes'].Length -le $level) {
            if ($index - 1 -lt $start) {
                return ''
            }

            return ($line[$start..($index - 1)] -join "`n")
        }
    }

    if ($start -lt 0) {
        return $null
    }

    if ($start -ge $line.Count) {
        return ''
    }

    $line[$start..($line.Count - 1)] -join "`n"
}

function ConvertTo-HandoffProse {
    <#
    .SYNOPSIS
        Blanks fenced blocks and inline code spans, keeping every line number.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $kept = [Collections.Generic.List[string]]::new()
    $inFence = $false
    foreach ($current in ($Text -split '\r?\n')) {
        if ($current -match '^\s*(```|~~~)') {
            $inFence = -not $inFence
            $kept.Add('')
            continue
        }

        if ($inFence) {
            $kept.Add('')
            continue
        }

        $kept.Add([regex]::Replace($current, '`[^`]*`', ''))
    }

    $kept -join "`n"
}

function Get-HandoffLink {
    <#
    .SYNOPSIS
        Returns the link targets outside code, with their line numbers.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $line = @((ConvertTo-HandoffProse -Text $Text) -split '\n')
    for ($index = 0; $index -lt $line.Count; $index++) {
        # The bounds keep a line of unclosed brackets from costing time that grows with its square.
        foreach ($match in [regex]::Matches($line[$index], '!?\[[^\]]{0,300}\]\((?<target>[^)\s]+)(?:\s+"[^"]{0,300}")?\)')) {
            [pscustomobject]@{ Line = $index + 1; Target = $match.Groups['target'].Value }
        }

        if ($line[$index] -match '^\s*\[[^\]]+\]:\s*(?<target>\S+)') {
            [pscustomobject]@{ Line = $index + 1; Target = $Matches['target'] }
        }
    }
}

function Get-HandoffMissingSlot {
    <#
    .SYNOPSIS
        Names the slots a brief has to carry and this text does not.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $missing = [Collections.Generic.List[string]]::new()
    $front = Get-HandoffFrontMatter -Text $Text
    if (-not $front.IsValid -or [string]::IsNullOrWhiteSpace([string]$front.Value['description'])) {
        $missing.Add('FrontMatter')
    } elseif ([string]::IsNullOrWhiteSpace([string]$front.Value['agent'])) {
        $missing.Add('Agent')
    }

    if ($Text -notmatch '\]\(00-README\.md(?:#[^)\r\n]{0,200})?\)') {
        $missing.Add('ReadmePointer')
    }

    $heading = [ordered]@{
        Preconditions = 'Preconditions?|Prerequisites?'
        Evidence      = 'Evidence'
        AskFirst      = 'Ask (?:first|before)'
        Tasks         = 'Tasks?|Steps|Required behavior|Approach'
        Validation    = 'Validation|Verification|Tests?'
        Finish        = 'Finish'
    }

    $found = @{}
    foreach ($name in $heading.Keys) {
        $found[$name] = $null -ne (Get-HandoffSection -Text $Text -HeadingPattern $heading[$name])
    }

    if (-not $found['Preconditions']) {
        $missing.Add('Preconditions')
    }

    if ($Text -notmatch '(?i)\bown row\b') {
        $missing.Add('OwnRowCheck')
    }

    if ($Text -notmatch '(?i)\bdependency rows?\b') {
        $missing.Add('DependencyCheck')
    }

    foreach ($name in @('Evidence', 'AskFirst', 'Tasks', 'Validation')) {
        if (-not $found[$name]) {
            $missing.Add($name)
        }
    }

    if (-not $found['Finish']) {
        $missing.Add('Finish')
    } else {
        $finish = [string](Get-HandoffSection -Text $Text -HeadingPattern $heading['Finish'])
        if ($finish -notmatch '(?i)\bcommit\b') {
            $missing.Add('FinishCommit')
        }

        if ($finish -notmatch '(?i)\breports[\\/]') {
            $missing.Add('FinishReport')
        }

        if ($finish -notmatch '(?i)\bledger\b') {
            $missing.Add('FinishLedger')
        }
    }

    $missing.ToArray()
}

function Get-HandoffReportStatus {
    <#
    .SYNOPSIS
        Returns the status a report states, or $null when it states none.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $status = 'Done with concerns|Done|Needs context|Blocked'
    $section = Get-HandoffSection -Text $Text -HeadingPattern 'Status'
    if ($null -ne $section) {
        foreach ($current in ($section -split '\n')) {
            if ($current -match ('^[\s`*_]*(?<status>' + $status + ')\b')) {
                return (Get-HandoffCanonicalStatus -Text $Matches['status'])
            }

            if (-not [string]::IsNullOrWhiteSpace($current)) {
                break
            }
        }
    }

    # Blanks and markers only, never a newline: a run of blank lines must not cost time that grows with its square.
    if ($Text -match ('(?im)^[ \t`*_]*status[ \t`*_]*[:\-][ \t`*_]*(?<status>' + $status + ')\b')) {
        return (Get-HandoffCanonicalStatus -Text $Matches['status'])
    }

    $null
}

function Get-HandoffCanonicalStatus {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Text
    )

    foreach ($status in @('Not started', 'Done with concerns', 'Done', 'Needs context', 'Blocked')) {
        if ($Text -ieq $status) {
            return $status
        }
    }

    $Text
}

function Get-HandoffFileHash {
    <#
    .SYNOPSIS
        Returns the SHA-256 of a file as upper-case hexadecimal.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    # .NET, not Get-FileHash: Windows PowerShell 5.1 cannot resolve that cmdlet inside a Pester It.
    $algorithm = [Security.Cryptography.SHA256]::Create()
    try {
        [BitConverter]::ToString($algorithm.ComputeHash([IO.File]::ReadAllBytes($Path))).Replace('-', '')
    } finally {
        $algorithm.Dispose()
    }
}

function Get-HandoffCommitToken {
    <#
    .SYNOPSIS
        Returns the commit-looking tokens a text names: a hexadecimal word of 7 to 40 characters with a digit and a letter.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text
    )

    $found = [Collections.Generic.List[string]]::new()
    foreach ($pattern in @('`(?<token>[0-9a-fA-F]{7,40})`', '(?i)\bcommits?\b[^\r\n]{0,40}?\b(?<token>[0-9a-f]{7,40})\b')) {
        foreach ($match in [regex]::Matches($Text, $pattern)) {
            $token = $match.Groups['token'].Value.ToLowerInvariant()
            # A digit and a letter: that leaves out words made of a-f and numbers such as a date.
            if ($token -match '[0-9]' -and $token -match '[a-f]' -and -not $found.Contains($token)) {
                $found.Add($token)
            }
        }
    }

    $found.ToArray()
}