scripts/internal/bootstrap/ProjectMetadataAccessor.ps1

<#
.SYNOPSIS
  Resolve a feature ref against project-local metadata and git merged-status.
.DESCRIPTION
  Resource accessor (IDesign): reads the feature's project-local presence (specs/<ref>/) and
  corroborates "not resumable" with git merged-status (data-storage d3). The feature ref is
  always re-resolved against the current project root - the committed absolute path is never
  trusted (FR-015). Git calls fail safe (a failure means "not provably merged", never a false
  clear). Feature 174 (FR-014, FR-015). Active-features registry + closeout-artifact signals
  are layered in later; presence + merged-status is the iteration-001 floor.
#>


function Test-SpecrewFeatureLocal {
    # specs/<ref>/ exists under the current project root.
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $SpecsRoot, [Parameter(Mandatory)][string] $FeatureRef)
    return (Test-Path -LiteralPath (Join-Path $SpecsRoot $FeatureRef) -PathType Container)
}

function Test-SpecrewBranchMergedToBase {
    # True when $Branch is fully merged into $BaseBranch (its tip is an ancestor of base).
    # Fails safe: any git error (missing branch/repo) returns $false, never a false "merged".
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string] $RepoRoot,
        [Parameter(Mandatory)][string] $Branch,
        [Parameter(Mandatory)][string] $BaseBranch
    )
    try {
        & git -C $RepoRoot merge-base --is-ancestor $Branch $BaseBranch 2>$null
        return ($LASTEXITCODE -eq 0)
    }
    catch { return $false }
}

function Get-SpecrewFeatureResumable {
    # Compose project-local presence + git merged-status into a resumability verdict.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter(Mandatory)][string] $FeatureRef,
        [Parameter()][string] $BaseBranch = 'main'
    )
    $present = Test-SpecrewFeatureLocal -SpecsRoot (Join-Path $ProjectRoot 'specs') -FeatureRef $FeatureRef
    $merged = $false
    if ($present) {
        $merged = Test-SpecrewBranchMergedToBase -RepoRoot $ProjectRoot -Branch $FeatureRef -BaseBranch $BaseBranch
    }
    [pscustomobject]@{
        feature_ref = $FeatureRef
        present     = $present
        merged      = $merged
        resumable   = ($present -and -not $merged)
    }
}

function Resolve-SpecrewBranchFeatureRef {
    # Resolve the active feature from the CURRENT git branch when no persisted anchor names one yet.
    # The pre-specify WORKSHOP window is the gap (F-174 T050): specs/<feature>/ already exists (Spec Kit's
    # create-new-feature scaffolds it before the workshop runs), but no boundary has crossed, so
    # start-context.json session_state.feature_ref is still blank. Without a feature, the Stop floor-writer
    # stamps an empty active_feature -> Test-SpecrewHandoverValidity returns 'no-feature' -> the handover is
    # NEVER surfaced on resume (the agent re-derives the whole situation from scratch - minutes). In Spec Kit
    # the branch name IS the feature slug (the specs dir shares its name), so the branch is the authoritative,
    # MULTI-FEATURE-SAFE key (a disk scan of specs/ would be ambiguous; the branch is not).
    # Returns the branch as the feature ref ONLY when it matches the Spec Kit feature-branch contract
    # (^\d{3}[-_]) AND specs/<branch>/ exists locally; else $null. So on main / a non-feature branch / a
    # closed feature whose dir was deleted -> $null (no bogus stamp; identical to today's behavior). The
    # read-side Test-SpecrewHandoverValidity still re-checks present + not-merged + the 24h freshness bound,
    # so a feature merged AFTER the stamp is caught on resume. Git failure -> $null (fail-safe). F-174 (T050).
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string] $ProjectRoot)

    $branch = $null
    try {
        $out = @(& git -C $ProjectRoot branch --show-current 2>$null)
        if ($LASTEXITCODE -eq 0 -and $out.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$out[0])) {
            $branch = ([string]$out[0]).Trim()
        }
    }
    catch { return $null }

    if ([string]::IsNullOrWhiteSpace($branch)) { return $null }
    if ($branch -notmatch '^\d{3}[-_]') { return $null }
    if (-not (Test-SpecrewFeatureLocal -SpecsRoot (Join-Path $ProjectRoot 'specs') -FeatureRef $branch)) { return $null }
    return $branch
}

function Get-SpecrewLensDecisionSummary {
    # F-174 iter-11 (T008, DF-1): a ONE-LINE decision recap for a design-workshop lens record, so a resume can
    # surface WHAT WAS DECIDED (the decision topics), not just the lens NAME (the iteration-010 multi-host
    # dogfood: pointer-mode hosts echoed lens names while the real decisions sat unread in the records).
    # Extracts the '## Decision N - <title>' headings (the design-workshop record convention) and joins their
    # titles, bounded. Fail-open: any read error / a record with no decision headings -> $null (the caller
    # falls back to the bare lens name).
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string] $RecordPath, [int] $MaxDecisions = 4, [int] $MaxLength = 220)
    try {
        if (-not (Test-Path -LiteralPath $RecordPath -PathType Leaf)) { return $null }
        $titles = New-Object System.Collections.Generic.List[string]
        foreach ($line in (Get-Content -LiteralPath $RecordPath -ErrorAction Stop)) {
            # '## Decision <N|Nb> [-|–|—|:] <title>' - anchor on the decision-id token (\S+) then the FIRST
            # separator, so an internal hyphen in the title (e.g. 'Atomic write-replace') is NOT mistaken for the
            # separator (review-signoff P5-1) and the em-dash (U+2014) is supported alongside hyphen/en-dash/colon.
            $m = [regex]::Match([string]$line, '^##\s+Decision\s+\S+\s*[-–—:]\s*(.+?)\s*$')
            if ($m.Success) {
                $t = ($m.Groups[1].Value -replace '`', '').Trim()
                if (-not [string]::IsNullOrWhiteSpace($t)) { $titles.Add($t) | Out-Null }
            }
        }
        if ($titles.Count -eq 0) { return $null }
        $shown = @($titles | Select-Object -First $MaxDecisions)
        $summary = ($shown -join '; ')
        if ($titles.Count -gt $MaxDecisions) { $summary += (' (+{0} more)' -f ($titles.Count - $MaxDecisions)) }
        if ($summary.Length -gt $MaxLength) { $summary = $summary.Substring(0, $MaxLength - 1).TrimEnd() + [char]0x2026 }
        return $summary
    }
    catch { return $null }
}

function Get-SpecrewWorkshopProgress {
    # Deterministic DISK-TRUTH scan of a feature's in-flight intent + status, for the bootstrap directive
    # (F-174 T050 round-2 finding): on resume, the intent (spec.md) and status (workshop records +
    # lens-applicability.json moved_on flags) ARE on disk, but nothing SURFACED them - so copilot asked
    # "what do you want to build" with the answer sitting in spec.md, and codex reported the hollow handover
    # then stopped ("re-derive from the artifacts" as an abstract pointer gets skimmed; surfaced CONTENT gets
    # followed - the iter-7 inline-the-contract lesson again). This accessor reads, the directive renders.
    # Fail open: any read error -> the empty shape (never blocks the bootstrap).
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter(Mandatory)][string] $FeatureRef
    )

    $featureDir = Join-Path (Join-Path $ProjectRoot 'specs') $FeatureRef
    $specPath = Join-Path $featureDir 'spec.md'
    $specExists = Test-Path -LiteralPath $specPath -PathType Leaf

    # Lens records persisted under the workshop folder (the per-lens durable checkpoints).
    $lensRecords = @()
    try {
        $wdir = Join-Path $featureDir 'workshop'
        if (Test-Path -LiteralPath $wdir -PathType Container) {
            $lensRecords = @(Get-ChildItem -LiteralPath $wdir -Filter '*.md' -File |
                    ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.Name) } |
                    Where-Object { $_ -ne 'lens-applicability' } | Sort-Object)
        }
    }
    catch { $lensRecords = @() }

    # lens-applicability.json: selected lenses + per-lens moved_on (done) flags. Hosts have written it both
    # feature-level (the skill's contract) and under workshop/ - accept either.
    $selected = @(); $done = @(); $applicabilityFound = $false
    foreach ($cand in @((Join-Path $featureDir 'lens-applicability.json'), (Join-Path (Join-Path $featureDir 'workshop') 'lens-applicability.json'))) {
        if (-not (Test-Path -LiteralPath $cand -PathType Leaf)) { continue }
        try {
            $la = Get-Content -LiteralPath $cand -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            $applicabilityFound = $true
            # StrictMode-safe local reads (this accessor stays self-contained; no SessionStateAccessor dep).
            $selProp = $la.PSObject.Properties['selected']
            if ($selProp -and $null -ne $selProp.Value) { $selected = @($selProp.Value | ForEach-Object { [string]$_ }) }
            $wProp = $la.PSObject.Properties['workshop']
            if ($wProp -and $null -ne $wProp.Value) {
                foreach ($p in $wProp.Value.PSObject.Properties) {
                    $mo = $p.Value.PSObject.Properties['moved_on']
                    if ($mo -and [bool]$mo.Value) { $done += [string]$p.Name }
                }
            }
            break
        }
        catch { continue }
    }

    # done = the union of moved_on records and persisted lens files (a host that writes the file but not the
    # json - codex - still counts as progressed); remaining = selected minus done, in selected order.
    $doneAll = @(@($done) + @($lensRecords) | Select-Object -Unique)
    $remaining = @($selected | Where-Object { $doneAll -notcontains $_ })

    # F-174 iter-11 (T008, DF-1): a one-line decision recap per DONE lens that has a workshop record, so the
    # resume directive can surface WHAT WAS DECIDED, not just the lens name. Ordered by $doneAll; only lenses
    # with an extractable decision summary appear (a record-less moved_on lens stays in `done` as a name only).
    $wdir = Join-Path $featureDir 'workshop'
    $doneDecisions = New-Object System.Collections.Generic.List[object]
    foreach ($lens in $doneAll) {
        $summary = Get-SpecrewLensDecisionSummary -RecordPath (Join-Path $wdir ($lens + '.md'))
        if (-not [string]::IsNullOrWhiteSpace($summary)) {
            $doneDecisions.Add([pscustomobject]@{ lens = [string]$lens; summary = $summary }) | Out-Null
        }
    }

    [pscustomobject]@{
        feature_ref    = $FeatureRef
        spec_exists    = $specExists
        spec_path      = if ($specExists) { "specs/$FeatureRef/spec.md" } else { $null }
        selected       = $selected
        done           = $doneAll
        # .ToArray() not @($doneDecisions): the array-subexpression operator on this List[object] of
        # pscustomobjects throws "Argument types do not match" as a hashtable value (a PowerShell quirk); the
        # explicit List.ToArray() is the reliable conversion.
        done_decisions = $doneDecisions.ToArray()
        remaining      = $remaining
        has_applicability = $applicabilityFound
        in_flight      = ($specExists -or ($doneAll.Count -gt 0))
    }
}

function Test-SpecrewWorkshopDecisionBindings {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)]$Applicability)

    $seen = [ordered]@{}
    $selected = if ($Applicability.PSObject.Properties['selected']) { @($Applicability.selected | ForEach-Object { [string]$_ }) } else { @() }
    $records = if ($Applicability.PSObject.Properties['workshop']) { $Applicability.workshop } else { $null }
    if ($null -eq $records) {
        return [pscustomobject]@{ valid = $true; reason = 'workshop-decision-bindings-absent'; conflict = $null }
    }

    foreach ($lens in $selected) {
        $entryProperty = $records.PSObject.Properties[$lens]
        if (-not $entryProperty -or $null -eq $entryProperty.Value) { continue }
        $entry = $entryProperty.Value
        $movedProperty = $entry.PSObject.Properties['moved_on']
        if (-not $movedProperty -or $movedProperty.Value -isnot [bool] -or -not [bool]$movedProperty.Value) { continue }

        $bindingsProperty = $entry.PSObject.Properties['bindings']
        if (-not $bindingsProperty) { continue } # Backward-compatible for workshop records created before bindings existed.
        $bindings = $bindingsProperty.Value
        if ($null -eq $bindings -or $bindings -is [System.Array] -or $bindings -is [string] -or $bindings -is [ValueType]) {
            return [pscustomobject]@{
                valid = $false; reason = 'workshop-decision-bindings-invalid'
                conflict = [pscustomobject]@{ binding = $null; prior_lens = $null; prior_value = $null; lens = $lens; value = $null }
            }
        }

        foreach ($bindingProperty in $bindings.PSObject.Properties) {
            $name = [string]$bindingProperty.Name
            $value = if ($bindingProperty.Value -is [string]) { ([string]$bindingProperty.Value).Trim() } else { '' }
            if ($name -cnotmatch '^[a-z][a-z0-9.-]{0,63}$' -or $value -cnotmatch '^[a-z0-9][a-z0-9._-]{0,127}$') {
                return [pscustomobject]@{
                    valid = $false; reason = 'workshop-decision-bindings-invalid'
                    conflict = [pscustomobject]@{ binding = $name; prior_lens = $null; prior_value = $null; lens = $lens; value = $value }
                }
            }
            if ($seen.Contains($name)) {
                $prior = $seen[$name]
                if ([string]$prior.value -cne $value) {
                    return [pscustomobject]@{
                        valid = $false; reason = 'workshop-decision-binding-conflict'
                        conflict = [pscustomobject]@{
                            binding = $name; prior_lens = [string]$prior.lens; prior_value = [string]$prior.value
                            lens = $lens; value = $value
                        }
                    }
                }
            }
            else { $seen[$name] = [pscustomobject]@{ lens = $lens; value = $value } }
        }
    }

    return [pscustomobject]@{ valid = $true; reason = 'workshop-decision-bindings-consistent'; conflict = $null }
}

function Get-SpecrewWorkshopLifecycleState {
    # Strict, controller-owned workshop lifecycle truth for Stop classification. Unlike
    # Get-SpecrewWorkshopProgress (a deliberately lenient bootstrap/resume projection), this accessor treats the
    # exact scoped lens-applicability.json as authority and requires every completed lens to carry the full skill
    # contract plus its durable Markdown record. Any ambiguous, stale, malformed, or partially-persisted completion
    # fails closed to `invalid`, so the Stop provider resumes ordinary enforcement instead of suppressing forever.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter(Mandatory)][string] $FeatureRef,
        [AllowNull()][string] $IterationNumber
    )

    $scope = if ([string]::IsNullOrWhiteSpace($IterationNumber)) { 'feature' } else { 'iteration' }
    $featureRoot = Join-Path (Join-Path $ProjectRoot 'specs') $FeatureRef
    $scopeRoot = if ($scope -eq 'iteration') { Join-Path $featureRoot ("iterations/{0}" -f $IterationNumber) } else { $featureRoot }
    $artifactPath = Join-Path $scopeRoot 'lens-applicability.json'
    $workshopRoot = Join-Path $scopeRoot 'workshop'

    function New-WorkshopStateResult {
        param(
            [string] $Status,
            [string] $Reason,
            [string[]] $Selected = @(),
            [string[]] $Completed = @(),
            [string[]] $Remaining = @(),
            [AllowNull()][string] $CurrentLens = $null,
            [AllowNull()][string] $AgendaStatus = $null,
            [AllowNull()]$BindingConflict = $null
        )
        [pscustomobject]@{
            status           = $Status
            valid            = ($Status -in @('active', 'complete'))
            reason           = $Reason
            scope            = $scope
            feature_ref      = $FeatureRef
            iteration_number = if ($scope -eq 'iteration') { $IterationNumber } else { $null }
            artifact_path    = $artifactPath
            selected         = @($Selected)
            completed        = @($Completed)
            remaining        = @($Remaining)
            current_lens     = $CurrentLens
            agenda_status    = $AgendaStatus
            binding_conflict = $BindingConflict
        }
    }

    try {
        if ($FeatureRef -cnotmatch '^[0-9]{3}-[a-z0-9][a-z0-9-]{0,63}$') {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-feature-ref-invalid'
        }
        if ($scope -eq 'iteration' -and $IterationNumber -cnotmatch '^[0-9]{3,}$') {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-iteration-number-invalid'
        }
        if (-not (Test-Path -LiteralPath $artifactPath -PathType Leaf)) {
            return New-WorkshopStateResult -Status 'absent' -Reason 'workshop-applicability-absent'
        }
        $item = Get-Item -LiteralPath $artifactPath -ErrorAction Stop
        if ($item.Length -le 0 -or $item.Length -gt 262144) {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-applicability-size-invalid'
        }
        $applicability = Get-Content -LiteralPath $artifactPath -Raw -Encoding UTF8 -ErrorAction Stop |
            ConvertFrom-Json -Depth 20 -ErrorAction Stop
        if ($null -eq $applicability -or $applicability -is [System.Array]) {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-applicability-root-invalid'
        }

        foreach ($requiredBoolean in @('workshop_intake', 'confirmation_required')) {
            $property = $applicability.PSObject.Properties[$requiredBoolean]
            if (-not $property -or $property.Value -isnot [bool] -or -not [bool]$property.Value) {
                return New-WorkshopStateResult -Status 'invalid' -Reason ("workshop-{0}-invalid" -f ($requiredBoolean -replace '_', '-'))
            }
        }

        # A new feature enters the workshop before a technical-lens agenda exists: product-domain questions run
        # first, and only their answers make the agenda informed. That phase still needs durable controller truth
        # so an ordinary question is not mistaken for a generic material-work stop. `pending-confirmation` is a
        # deliberately narrow feature-only shape. Once the human confirms the agenda, the writer changes it to
        # `confirmed` and supplies the ordinary nonempty selected list. Artifacts that predate this field remain
        # compatible and are treated as confirmed.
        $agendaStatus = 'confirmed'
        $agendaStatusProperty = $applicability.PSObject.Properties['agenda_status']
        if ($agendaStatusProperty) {
            if ($agendaStatusProperty.Value -isnot [string] -or
                [string]$agendaStatusProperty.Value -cnotin @('pending-confirmation', 'confirmed')) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-agenda-status-invalid'
            }
            $agendaStatus = [string]$agendaStatusProperty.Value
        }

        $selectedProperty = $applicability.PSObject.Properties['selected']
        if (-not $selectedProperty -or $selectedProperty.Value -isnot [System.Array]) {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-selected-invalid' -AgendaStatus $agendaStatus
        }
        $selected = @($selectedProperty.Value | ForEach-Object { [string]$_ })

        $workshopProperty = $applicability.PSObject.Properties['workshop']
        if (-not $workshopProperty -or $null -eq $workshopProperty.Value -or
            $workshopProperty.Value -is [System.Array] -or $workshopProperty.Value -is [string] -or
            $workshopProperty.Value -is [ValueType]) {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-record-map-invalid' -Selected $selected -AgendaStatus $agendaStatus
        }
        $records = $workshopProperty.Value

        if ($agendaStatus -eq 'pending-confirmation') {
            if ($scope -ne 'feature') {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-pre-agenda-iteration-invalid' -Selected $selected -AgendaStatus $agendaStatus
            }
            if ($selected.Count -ne 0) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-pre-agenda-selected-invalid' -Selected $selected -AgendaStatus $agendaStatus
            }
            if (@($records.PSObject.Properties).Count -ne 0) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-pre-agenda-records-invalid' -AgendaStatus $agendaStatus
            }

            $specPath = Join-Path $featureRoot 'spec.md'
            if (-not (Test-Path -LiteralPath $specPath -PathType Leaf)) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-pre-agenda-spec-missing' -AgendaStatus $agendaStatus
            }
            $specItem = Get-Item -LiteralPath $specPath -ErrorAction Stop
            if ($specItem.Length -le 0 -or $specItem.Length -gt 1048576) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-pre-agenda-spec-size-invalid' -AgendaStatus $agendaStatus
            }

            return New-WorkshopStateResult -Status 'active' -Reason 'workshop-pre-agenda-active' `
                -Remaining @('product-domain') -CurrentLens 'product-domain' -AgendaStatus $agendaStatus
        }

        if ($selected.Count -eq 0) {
            return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-selected-empty' -AgendaStatus $agendaStatus
        }
        $seenSelected = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
        foreach ($lens in $selected) {
            if ($lens -cnotmatch '^[a-z][a-z0-9-]{1,63}$' -or -not $seenSelected.Add($lens)) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-selected-lens-invalid' -Selected $selected -AgendaStatus $agendaStatus
            }
        }

        foreach ($recordProperty in $records.PSObject.Properties) {
            if ([string]$recordProperty.Name -cnotin $selected) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-record-not-selected' -Selected $selected -AgendaStatus $agendaStatus
            }
        }

        $completed = New-Object System.Collections.Generic.List[string]
        $remaining = New-Object System.Collections.Generic.List[string]
        $foundIncomplete = $false
        $confirmationScopes = @{
            'human-confirmed' = 'lens-question'
            'human-delegated' = 'explicit-delegation'
            'human-skipped'   = 'explicit-skip'
        }

        foreach ($lens in $selected) {
            $entryProperty = $records.PSObject.Properties[$lens]
            $entry = if ($entryProperty) { $entryProperty.Value } else { $null }
            $movedOn = $false
            if ($null -ne $entry) {
                if ($entry -is [System.Array] -or $entry -is [string] -or $entry -is [ValueType]) {
                    return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-lens-record-invalid' -Selected $selected -Completed $completed.ToArray()
                }
                $movedProperty = $entry.PSObject.Properties['moved_on']
                if ($movedProperty) {
                    if ($movedProperty.Value -isnot [bool]) {
                        return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-moved-on-invalid' -Selected $selected -Completed $completed.ToArray()
                    }
                    $movedOn = [bool]$movedProperty.Value
                }
            }

            if (-not $movedOn) {
                $foundIncomplete = $true
                $remaining.Add($lens) | Out-Null
                continue
            }
            if ($foundIncomplete) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-completion-out-of-order' -Selected $selected -Completed $completed.ToArray() -Remaining $remaining.ToArray()
            }

            $agendaProperty = $entry.PSObject.Properties['agenda']
            if (-not $agendaProperty -or $agendaProperty.Value -isnot [System.Array] -or @($agendaProperty.Value).Count -eq 0 -or
                @($agendaProperty.Value | Where-Object { $_ -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-completed-agenda-invalid' -Selected $selected -Completed $completed.ToArray()
            }
            foreach ($requiredString in @('decision', 'depth', 'confirmation', 'confirmation_scope')) {
                $property = $entry.PSObject.Properties[$requiredString]
                if (-not $property -or $property.Value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
                    return New-WorkshopStateResult -Status 'invalid' -Reason ("workshop-completed-{0}-invalid" -f ($requiredString -replace '_', '-')) -Selected $selected -Completed $completed.ToArray()
                }
            }
            $confirmation = [string]$entry.confirmation
            if (-not $confirmationScopes.ContainsKey($confirmation) -or [string]$entry.confirmation_scope -cne [string]$confirmationScopes[$confirmation]) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-completed-confirmation-invalid' -Selected $selected -Completed $completed.ToArray()
            }

            $recordPath = Join-Path $workshopRoot ($lens + '.md')
            if (-not (Test-Path -LiteralPath $recordPath -PathType Leaf)) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-completed-record-missing' -Selected $selected -Completed $completed.ToArray()
            }
            $recordItem = Get-Item -LiteralPath $recordPath -ErrorAction Stop
            if ($recordItem.Length -le 0 -or $recordItem.Length -gt 262144) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-completed-record-size-invalid' -Selected $selected -Completed $completed.ToArray()
            }
            $recordText = Get-Content -LiteralPath $recordPath -Raw -Encoding UTF8 -ErrorAction Stop
            if ([string]::IsNullOrWhiteSpace($recordText)) {
                return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-completed-record-empty' -Selected $selected -Completed $completed.ToArray()
            }
            $completed.Add($lens) | Out-Null
        }

        $bindingValidation = Test-SpecrewWorkshopDecisionBindings -Applicability $applicability
        if (-not $bindingValidation.valid) {
            return New-WorkshopStateResult -Status 'invalid' -Reason ([string]$bindingValidation.reason) -Selected $selected -Completed $completed.ToArray() -Remaining $remaining.ToArray() -BindingConflict $bindingValidation.conflict
        }

        if ($remaining.Count -eq 0) {
            return New-WorkshopStateResult -Status 'complete' -Reason 'workshop-complete' -Selected $selected -Completed $completed.ToArray() -AgendaStatus $agendaStatus
        }
        return New-WorkshopStateResult -Status 'active' -Reason 'workshop-active' -Selected $selected -Completed $completed.ToArray() -Remaining $remaining.ToArray() -CurrentLens $remaining[0] -AgendaStatus $agendaStatus
    }
    catch {
        return New-WorkshopStateResult -Status 'invalid' -Reason 'workshop-state-unreadable'
    }
}

function Test-SpecrewIsGitRepoRoot {
    # F-174 iter-10 (Prop-145 round-6, HIGH): is $ProjectRoot the TOP-LEVEL of its own git repo (or a worktree
    # root)? `git rev-parse --show-prefix` answers in O(repo-depth), NOT O(tree): empty output + exit 0 ==
    # $ProjectRoot IS the top-level (a linked worktree root also returns empty -> passes); a NON-empty prefix ==
    # $ProjectRoot sits BELOW some repo's root (nested); a non-zero exit == not a git repo at all. Why this and
    # not `--show-toplevel` + a path-compare: the toplevel path comes back in git's casing/slash form and a temp
    # root under an 8.3-short HOME ($env:TEMP = C:\Users\ALON~1.HOM) never string-equals git's C:\Users\alon.HOME
    # -> a false "nested" on the developer's own machine. --show-prefix sidesteps all path normalization. The
    # gate exists so Get-SpecrewSessionDelta never runs `git status` against a PARENT repo it merely lives inside
    # (e.g. a non-repo project root under a HOME that is itself a worktree) - that scan walks the WHOLE parent
    # tree and can hang the hook. Fail-safe: any error -> $false (treat as "not a clean repo root", skip the scan).
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $ProjectRoot)
    try {
        $prefix = (& git -C $ProjectRoot rev-parse --show-prefix 2>$null)
        return (($LASTEXITCODE -eq 0) -and [string]::IsNullOrEmpty(([string]$prefix).Trim()))
    }
    catch { return $false }
}

function Get-SpecrewGitScanScope {
    # iter-007 (real-host dogfood): resolve HOW to scope a git scan to $ProjectRoot's OWN subtree, tolerant of
    # $ProjectRoot being a SUBDIR of a larger repo (a Specrew project NESTED in a monorepo - the case that
    # hollowed EnglishIntake's handover). Returns:
    # InWorkTree : is $ProjectRoot inside ANY git work tree (the repo root OR a subdir)? Replaces the
    # is-repo-ROOT gate: a nested project is now scanned (scoped), not skipped.
    # Prefix : the repo-root-relative prefix of $ProjectRoot ('' when it IS the repo root; e.g.
    # 'Tools/EnglishIntake/' when nested). Callers strip this to map git's repo-root-relative
    # paths back to project-relative.
    # Pairs with the `-- .` pathspec the callers add: that confines the scan to the cwd (-C $ProjectRoot)
    # subtree so a nested project NEVER scans the unbounded parent tree (the original hook-hang hazard the
    # is-repo-root gate guarded against). Fail-safe: any error / not-in-a-work-tree -> InWorkTree=$false so the
    # caller degrades to the empty/no-scan shape, exactly as before. This helper is the seam the deferred
    # navigator subtree-scoping will reuse (see iterations/007/subtree-scoping-seed.md).
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string] $ProjectRoot)
    try {
        $inside = (& git -C $ProjectRoot rev-parse --is-inside-work-tree 2>$null)
        if ($LASTEXITCODE -ne 0 -or ([string]$inside).Trim() -ne 'true') {
            return [pscustomobject]@{ InWorkTree = $false; Prefix = '' }
        }
        $prefix = ([string](& git -C $ProjectRoot rev-parse --show-prefix 2>$null)).Trim()
        return [pscustomobject]@{ InWorkTree = $true; Prefix = $prefix }
    }
    catch { return [pscustomobject]@{ InWorkTree = $false; Prefix = '' } }
}

function Get-SpecrewEmptySessionDelta {
    # The empty/zero delta shape Get-SpecrewSessionDelta returns when there is no git scan to run (not a repo
    # root). Single source of truth so the gate and the happy path can never drift in shape.
    [OutputType([pscustomobject])]
    param()
    [pscustomobject]@{
        branch                = ''
        head_short            = ''
        head_subject          = ''
        uncommitted_count     = 0
        uncommitted_files     = @()
        uncommitted_truncated = $false
        has_uncommitted       = $false
        user_file_count       = 0
        user_files            = @()
        managed_file_count    = 0
        new_commits           = @()
        new_commit_count      = 0
    }
}

function Get-SpecrewSessionDelta {
    # F-174 iter-9: the git/filesystem delta the Stop hook captures as the rolling handover's MECHANICAL
    # body - host-universal, needs NO transcript and NO agent cooperation (the iter-8 dogfood proved the
    # agent-/gate-dependent author is hollow in practice; the git delta is always available). Fail-safe: any
    # git error yields the empty/zero shape so the handover degrades, never throws. $SinceCommit (the prior
    # handover's from_commit) bounds "new commits this session"; $null/unresolvable -> no new-commit list.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter()][AllowNull()][string] $SinceCommit,
        [Parameter()][int] $MaxFiles = 12,
        [Parameter()][int] $MaxCommits = 8
    )
    # Prop-145 round-6 (HIGH): gate the scan on "$ProjectRoot is its own repo root". When it is NOT (a non-repo
    # project root that merely sits under a parent git repo / worktree), `git status --untracked-files=all`
    # would scan the entire PARENT tree - unbounded, hangs the hook, and reports the parent's files as this
    # project's delta. Returning the empty shape here is the same fail-safe degrade as any git error.
    # iter-007 fix: scope the scan to $ProjectRoot's OWN subtree, tolerant of a project NESTED in a larger repo
    # (governance-root != git-root). InWorkTree=$false (not inside any git work tree) keeps the original
    # fail-safe: degrade to the empty shape, never scan a parent repo unbounded. Prefix maps the scoped scan's
    # repo-root-relative paths back to project-relative. (Dogfood: EnglishIntake under iTeach-Avatar hollowed.)
    $scanScope = Get-SpecrewGitScanScope -ProjectRoot $ProjectRoot
    if (-not $scanScope.InWorkTree) { return Get-SpecrewEmptySessionDelta }

    $branch = ''; $headShort = ''; $headSubject = ''
    try { $branch = ([string](& git -C $ProjectRoot rev-parse --abbrev-ref HEAD 2>$null)).Trim() } catch { $null = $_ }
    try { $headShort = ([string](& git -C $ProjectRoot rev-parse --short HEAD 2>$null)).Trim() } catch { $null = $_ }
    try { $headSubject = ([string](& git -C $ProjectRoot log -1 --format=%s 2>$null)).Trim() } catch { $null = $_ }

    $uncommittedFiles = @()
    try {
        # --untracked-files=all expands untracked DIRECTORIES (e.g. specs/) into their individual files so the
        # user's real work (specs/<feature>/spec.md, workshop/<lens>.md) surfaces instead of a bare "specs/".
        # `-- .` confines the scan to $ProjectRoot's subtree (the cwd via -C) so a nested project never walks the
        # parent repo. status --porcelain returns REPO-ROOT-relative paths, so strip the subtree prefix back to
        # project-relative (Prefix='' at the repo root -> the strip is a no-op).
        $porcelain = @(& git -C $ProjectRoot status --porcelain --untracked-files=all -- . 2>$null) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        $uncommittedFiles = @($porcelain | ForEach-Object {
                $p = ($_ -replace '^..\s+', '').Trim()
                if ($scanScope.Prefix -and $p) { $p = ($p -replace ('^' + [regex]::Escape($scanScope.Prefix)), '') }
                $p
            } | Where-Object { $_ })
    }
    catch { $uncommittedFiles = @() }

    # F-174 dogfood fix: the Specrew/Squad/Spec-Kit managed scaffolding (.agents/.claude/.copilot/.cursor/
    # .github/.specify/.squad/.specrew + the init config files) sorts first in `git status` and was filling
    # the MaxFiles cap, capping OUT the user's REAL work (specs/, src/) - so the rolling handover surfaced
    # the same ~53 scaffolding paths every refresh and never the actual workshop/spec files. Partition
    # managed vs user and surface USER files FIRST so the real work is never drowned or capped.
    $managedRegex = '^\.(agents|claude|copilot|cursor|github|specify|squad|specrew)[/\\]|^\.(gitattributes|gitignore|markdownlint\.json)$'
    $userFiles = @($uncommittedFiles | Where-Object { ($_ -replace '\\', '/') -notmatch $managedRegex })
    $managedFiles = @($uncommittedFiles | Where-Object { ($_ -replace '\\', '/') -match $managedRegex })
    $prioritized = @(@($userFiles) + @($managedFiles))

    $newCommits = @()
    if (-not [string]::IsNullOrWhiteSpace($SinceCommit)) {
        try {
            # `-- .` scopes "new commits this session" to commits touching $ProjectRoot's subtree (a nested
            # project's own commits, not the whole monorepo's).
            $log = @(& git -C $ProjectRoot log --oneline ("{0}..HEAD" -f $SinceCommit) -- . 2>$null) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
            if ($LASTEXITCODE -eq 0) { $newCommits = @($log) }
        }
        catch { $newCommits = @() }
    }

    [pscustomobject]@{
        branch                = $branch
        head_short            = $headShort
        head_subject          = $headSubject
        uncommitted_count     = $uncommittedFiles.Count
        uncommitted_files     = @($prioritized | Select-Object -First $MaxFiles)
        uncommitted_truncated = ($uncommittedFiles.Count -gt $MaxFiles)
        has_uncommitted       = ($uncommittedFiles.Count -gt 0)
        user_file_count       = $userFiles.Count
        user_files            = @($userFiles | Select-Object -First $MaxFiles)
        managed_file_count    = $managedFiles.Count
        new_commits           = @($newCommits | Select-Object -First $MaxCommits)
        new_commit_count      = $newCommits.Count
    }
}

function Get-SpecrewResumeReconciliation {
    # F-174 iter-10 (T001): the SHARED, CHEAP resume reconciliation. On RESUME, re-compute the CURRENT git
    # delta (one `git status` via Get-SpecrewSessionDelta) bounded by the handover's from_commit, so the
    # resuming agent is handed the ACTUAL tree state - NOT a stale last-stop snapshot - and is DIRECTED to
    # read what changed since and continue from the real state (the snapshot may predate the latest work; a
    # hard kill / no-PostToolUse host / antigravity all leave the handover behind the disk). Called by BOTH
    # the SessionStart hook (Invoke-SpecrewSessionBootstrap) AND `specrew start` (T008) so recovery is
    # host-universal. Lean by contract: ONE delta computation; the agent does the reading (the resume budget
    # is already spent on the launch contract). Fail-safe: any error yields $null and the resume degrades to
    # the snapshot, never throws.
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter()][AllowNull()][pscustomobject] $Handover
    )
    $sinceCommit = if ($null -ne $Handover) { [string]$Handover.from_commit } else { $null }
    $delta = $null
    try { $delta = Get-SpecrewSessionDelta -ProjectRoot $ProjectRoot -SinceCommit $sinceCommit } catch { $delta = $null }
    if ($null -eq $delta) { return $null }

    $lastStop = if ($null -ne $Handover) { [string]$Handover.recorded_at } else { '' }
    $lastBoundary = if ($null -ne $Handover) { [string]$Handover.active_boundary } else { '' }
    $changedNow = @($delta.user_files)
    $newCommits = @($delta.new_commits)

    $lines = New-Object System.Collections.Generic.List[string]
    if (-not [string]::IsNullOrWhiteSpace($lastStop)) {
        $bn = if (-not [string]::IsNullOrWhiteSpace($lastBoundary)) { " (boundary $lastBoundary)" } else { '' }
        $lines.Add(("Last captured stop: {0}{1}." -f $lastStop, $bn)) | Out-Null
    }
    if ($changedNow.Count -gt 0) {
        $more = if (([int]$delta.user_file_count) -gt $changedNow.Count) { ', +more' } else { '' }
        $lines.Add(("Files changed since (re-computed NOW - may post-date the last stop): {0}{1}." -f ($changedNow -join ', '), $more)) | Out-Null
        $lines.Add('READ those files to recover the true current state (the handover snapshot may predate your latest work), THEN continue.') | Out-Null
    }
    elseif (([int]$delta.managed_file_count) -gt 0) {
        $lines.Add(("No user files changed since the last commit ({0} Specrew-managed scaffolding uncommitted); continue the next lifecycle step." -f $delta.managed_file_count)) | Out-Null
    }
    else {
        $lines.Add('Working tree is clean since the last commit; continue the next lifecycle step.') | Out-Null
    }
    if ($newCommits.Count -gt 0) { $lines.Add(("New commits since the handover: {0}." -f ($newCommits -join ' | '))) | Out-Null }

    [pscustomobject]@{
        last_stop_recorded_at = $lastStop
        last_boundary         = $lastBoundary
        branch                = [string]$delta.branch
        head_short            = [string]$delta.head_short
        changed_user_files    = $changedNow
        user_file_count       = [int]$delta.user_file_count
        managed_file_count    = [int]$delta.managed_file_count
        new_commits           = $newCommits
        directive_text        = ($lines -join ' ')
    }
}