extensions/specrew-speckit/scripts/specrew-conformance-provider.ps1

# Feature 185 FR-011 / FR-015 / FR-004 / FR-005 - the conformance Stop-provider (DETECTION + BLOCK delivery).
#
# This is a CONSUMER of the EXISTING hook dispatcher + provider catalog (refocus-scopes.json), registered
# as kind=inject events=[Stop] order=40 - it runs AFTER the handover provider (order 30) has done the
# verdict capture (which advances last_authorized_boundary via the authorization writer on a captured marker).
# It is an isolated script the dispatcher invokes; it does NOT edit HandoverStore.ps1 and NEVER calls the
# verdict-authority write path - it is a strictly READ-ONLY consumer of the gate STATE, so it physically
# cannot break what keeps the lifecycle honest. Its only writes are best-effort diagnostics under
# .specrew/runtime/ (the conformance journal + the stop-block loop-guard counter), never gate state.
#
# ARG CONTRACT: the dispatcher invokes inject providers with DOUBLE-dash flags (--host-kind, --source-event,
# --transcript-path) via ProcessStartInfo.ArgumentList. PowerShell's single-dash `param()` binding REJECTS
# a `--flag` token (it reads as `-flag`), so a `param()`/[CmdletBinding()] block makes the script exit 1 at
# the binding boundary BEFORE its body runs. So parse $args MANUALLY (the handover provider's convention). NO param().
#
# DELIVERY = BLOCK AT THE STOP (FR-015 maintainer ruling 2026-06-20): the 6-section re-entry packet must render
# AT the stop, not as a too-late next-turn nudge - a packet-less stop already leaves the human puzzled. So when a
# stop owes the packet and it is absent, this provider emits a BLOCK SENTINEL (`<<<SPECREW-STOP-BLOCK>>>` + the
# directive); the dispatcher translates it into the host's stop-block envelope (verified capability matrix,
# research/stop-block-capability-matrix.md: claude/codex/copilot decision:block, antigravity decision:continue,
# cursor followup_message), force-continuing the turn so the agent renders the packet before control returns.
#
# WHAT OWES THE PACKET (block triggers; the design workshop is the only exclusion):
# - BOUNDARY stop: HasPendingVerdict (working boundary ahead of last-authorized, no captured verdict - the #2884
# silent advance). REUSES the canonical Get-SpecrewPendingVerdictState (FR-008; not a parallel inference engine).
# The block directive carries the CONTIGUOUS last_authorized -> successor verdict marker (145 F2).
# - MATERIAL non-boundary stop: the current Stop handover reports changed user files or new commits, but the last
# assistant message lacks the five-part context packet. This uses the same rolling-handover material signal as
# the resume floor and deliberately does NOT block on prose length alone.
# FALSE-POSITIVE GUARD: if the last assistant message already surfaced the exact pending boundary crossing (the
# marker the capture path would accept) -> no block.
#
# #1 INTAKE QUESTION (asking "what to build" while a spec exists) and #3 RAW `specify[.exe] workflow`: cooperative
# redirects, folded into the block directive when a block fires, else emitted as a plain inject nudge.
#
# LOOP GUARD (never hang a session): claude/codex have a built-in stop_hook_active cap (the dispatcher also
# honours it - it does NOT block when already continuing). copilot/antigravity have none, so this provider keeps
# its OWN consecutive-block counter (.specrew/runtime/conformance-stop-block.json), capped at $BLOCK_CAP within a
# short window; over the cap -> stop blocking, degrade to a plain nudge, never trap. Reset when the packet appears.
#
# HONEST CEILINGS: (1) cursor cannot hard-block (followup_message re-triggers a NEW turn - the human may glimpse the
# packet-less stop); declared best-effort. (2) capability != firing reliability - codex Stop does not fire on an
# Esc-interrupted turn / headless exec (a real-host dogfood concern). (3) DETECTION SCOPE: boundary enforcement
# keys off gate state; material-work enforcement keys off the current rolling-handover Stop snapshot. If either
# signal is unavailable, the provider fails open. (4) the design-analysis lens workshop is explicitly excluded.
# Fully FAIL-OPEN: any error / uncertainty
# degrades to NO block (allow the stop) - blocking is the narrow exception, never the default.

$ErrorActionPreference = 'Stop'
try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { $null = $_ }  # best-effort UTF-8 (child half of the dispatcher's encoding contract)

$script:SpecrewReentryHeaders = @('What I Just Did', 'Why I Stopped', 'What Needs Your Review', 'What Happens Next', 'Discussion Prompts', 'What I Need From You')
$script:SpecrewBlockCap = 3
$script:SpecrewFireDedupWindowSec = 60  # idempotency: a duplicate hook fire for the SAME observable state within this window is a no-op.
$script:SpecrewMaterialHandoverMaxAgeSec = 300  # handover provider runs immediately before conformance; older snapshots are stale.
$script:SpecrewMaterialRetryWindowSec = 600  # after a material stop block, keep enforcing only during the forced-continue loop.
$script:SpecrewSubstantialChars = 600

function Test-SpecrewReentryPacketPresent {
    # >=4 of the 6 canonical section-header phrases present in the (flattened) last assistant message = the packet
    # was rendered. Phrase-based (not '## '-prefixed) so it survives the transcript flattening; >=4 (not all 6)
    # tolerates minor wording drift without letting a bare message through.
    param([AllowNull()][string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
    $hits = 0
    foreach ($h in $script:SpecrewReentryHeaders) { if ($Text -match [regex]::Escape($h)) { $hits++ } }
    return ($hits -ge 4)
}

function Get-SpecrewBlockCount {
    # Consecutive-block count for THIS advance ($Key = "<working>|<lastAuth>"). 0 if absent / a DIFFERENT advance /
    # unreadable. Keyed by the advance identity (NOT a time window): the count accumulates across consecutive
    # packet-less stops for the same advance regardless of how long each forced-continue turn takes (145 HANG-1: a
    # time window let a >120s/turn loop reset to 0 forever and never cap). A different advance is a fresh sequence.
    param([string]$Path, [string]$Key)
    try {
        if (Test-Path -LiteralPath $Path -PathType Leaf) {
            $rec = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
            if (($rec.PSObject.Properties.Name -contains 'count') -and ($rec.PSObject.Properties.Name -contains 'key') -and ([string]$rec.key -eq $Key)) {
                return [int]$rec.count
            }
        }
    }
    catch { $null = $_ }
    return 0
}

function Get-SpecrewBlockRecord {
    param([string]$Path)
    try {
        if (Test-Path -LiteralPath $Path -PathType Leaf) {
            $rec = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
            if (($rec.PSObject.Properties.Name -contains 'key') -and ($rec.PSObject.Properties.Name -contains 'count')) {
                return $rec
            }
        }
    }
    catch { $null = $_ }
    return $null
}

function Set-SpecrewBlockCount {
    # Persist the count for $Key and VERIFY it landed (read-back). Returns $true only when the increment is durably
    # readable - the caller blocks ONLY on $true, so a persistent / non-atomic write failure can never start an
    # uncappable block loop on a host without a built-in cap (145 HANG-2 fail-open).
    param([string]$Path, [string]$Key, [int]$Count)
    try {
        $dir = Split-Path -Parent $Path
        if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        ([pscustomobject]@{ key = $Key; count = $Count; epoch = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } | ConvertTo-Json -Compress) | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop
        if (Test-Path -LiteralPath $Path -PathType Leaf) {
            $back = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
            if (($back.PSObject.Properties.Name -contains 'count') -and ([int]$back.count -eq $Count) -and ($back.PSObject.Properties.Name -contains 'key') -and ([string]$back.key -eq $Key)) { return $true }
        }
    }
    catch { $null = $_ }
    return $false
}

function Get-SpecrewRecentMaterialRetryKey {
    param([AllowNull()]$Record)
    try {
        if ($null -eq $Record -or -not ($Record.PSObject.Properties.Name -contains 'key')) { return $null }
        $key = [string]$Record.key
        if ($key -notlike 'material|*') { return $null }
        if (-not ($Record.PSObject.Properties.Name -contains 'epoch')) { return $null }
        $age = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - [long]$Record.epoch
        if ($age -ge 0 -and $age -le $script:SpecrewMaterialRetryWindowSec) { return $key }
    }
    catch { $null = $_ }
    return $null
}

function Get-SpecrewMaterialSatisfiedKey {
    param([string]$Path)
    try {
        if (Test-Path -LiteralPath $Path -PathType Leaf) {
            $rec = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
            if (($rec.PSObject.Properties.Name -contains 'key') -and -not [string]::IsNullOrWhiteSpace([string]$rec.key)) {
                return [string]$rec.key
            }
        }
    }
    catch { $null = $_ }
    return $null
}

function Set-SpecrewMaterialSatisfiedKey {
    param([string]$Path, [string]$Key)
    if ([string]::IsNullOrWhiteSpace($Key)) { return }
    try {
        $dir = Split-Path -Parent $Path
        if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        ([pscustomobject]@{ key = $Key; epoch = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } | ConvertTo-Json -Compress) | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop
    }
    catch { $null = $_ }
}

function Get-SpecrewCurrentStopMaterialSignal {
    # Deterministic non-boundary packet trigger. The handover provider (order 30) runs before this conformance
    # provider (order 40) and writes the latest Stop snapshot. Treat it as material ONLY when the current, fresh
    # Stop snapshot's newest activity bullet reports changed user files or new commits. Conversation-only Stop
    # refreshes update recorded_at but do not prepend an activity bullet, so their bullet timestamp will not match.
    param([string]$ProjectRoot, [AllowNull()][string]$BootstrapDir)
    $result = [pscustomobject]@{ material = $false; key = $null; reason = 'no-material-signal'; user_file_count = 0; new_commit_count = 0; active_feature = $null; active_boundary = $null }
    try {
        if ([string]::IsNullOrWhiteSpace($BootstrapDir)) { $result.reason = 'no-bootstrap-dir'; return $result }
        $store = Join-Path $BootstrapDir 'HandoverStore.ps1'
        if (-not (Test-Path -LiteralPath $store -PathType Leaf)) { $result.reason = 'no-handover-store'; return $result }
        if (-not (Get-Command ConvertFrom-SpecrewHandoverFile -ErrorAction SilentlyContinue)) {
            try { . $store } catch { $result.reason = 'handover-store-unloadable'; return $result }
        }
        if (-not (Get-Command ConvertFrom-SpecrewHandoverFile -ErrorAction SilentlyContinue)) { $result.reason = 'handover-parser-unavailable'; return $result }

        $path = Join-Path $ProjectRoot '.specrew/handover/session-handover.md'
        if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { $result.reason = 'no-handover'; return $result }
        $handover = ConvertFrom-SpecrewHandoverFile -Path $path
        if ($null -eq $handover) { $result.reason = 'unreadable-handover'; return $result }
        $result.active_feature = [string]$handover.active_feature
        $result.active_boundary = [string]$handover.active_boundary

        $source = [string]$handover.source
        if ([string]::IsNullOrWhiteSpace($source) -or $source.ToLowerInvariant() -notin @('stop', 'agentstop')) {
            $result.reason = 'not-stop-handover'; return $result
        }

        $recordedRaw = [string]$handover.recorded_at
        if ([string]::IsNullOrWhiteSpace($recordedRaw)) { $result.reason = 'missing-recorded-at'; return $result }
        $recordedAt = [datetime]::Parse($recordedRaw).ToUniversalTime()
        $age = ([datetime]::UtcNow - $recordedAt).TotalSeconds
        if ($age -lt -30 -or $age -gt $script:SpecrewMaterialHandoverMaxAgeSec) {
            $result.reason = 'stale-handover'; return $result
        }

        $activityTitle = 'What I just did (last 3-5 turns or last boundary work)'
        $activity = if ($handover.sections -and $handover.sections.Contains($activityTitle)) { [string]$handover.sections[$activityTitle] } else { '' }
        if ([string]::IsNullOrWhiteSpace($activity)) { $result.reason = 'no-activity-section'; return $result }
        $bullet = @($activity -split "`r?`n" | Where-Object { $_ -match '^\s*-\s+\[' } | Select-Object -First 1)
        if ($bullet.Count -eq 0) { $result.reason = 'no-activity-bullet'; return $result }

        $bulletText = [string]$bullet[0]
        $rx = [regex]::new('^\s*-\s+\[(?<stamp>[^\]]+)\]\s+\((?<source>[^)]+)\)\s+(?<files>\d+)\s+changed user file\(s\)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        $m = $rx.Match($bulletText)
        if (-not $m.Success) { $result.reason = 'activity-unrecognized'; return $result }
        if (($m.Groups['source'].Value).ToLowerInvariant() -notin @('stop', 'agentstop')) { $result.reason = 'activity-not-stop'; return $result }

        $activityAt = [datetime]::Parse($m.Groups['stamp'].Value).ToUniversalTime()
        if ([math]::Abs(($recordedAt - $activityAt).TotalSeconds) -gt 5) {
            $result.reason = 'activity-not-current-stop'; return $result
        }

        $files = [int]$m.Groups['files'].Value
        $commitMatch = [regex]::Match($bulletText, ';\s+(?<commits>\d+)\s+new commit\(s\)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        $commits = if ($commitMatch.Success -and -not [string]::IsNullOrWhiteSpace($commitMatch.Groups['commits'].Value)) { [int]$commitMatch.Groups['commits'].Value } else { 0 }
        if ($files -le 0 -and $commits -le 0) { $result.reason = 'no-user-files-or-commits'; return $result }

        $result.material = $true
        $result.reason = 'current-stop-material-delta'
        $result.user_file_count = $files
        $result.new_commit_count = $commits
        $stableMaterialSurface = ($bulletText -replace '^\s*-\s+\[[^\]]+\]\s+\([^)]+\)\s+', '').Trim()
        $surfaceHash = Get-SpecrewFireIdentity -Parts @($stableMaterialSurface)
        $result.key = ('material|{0}' -f $surfaceHash)
        return $result
    }
    catch {
        $result.reason = 'material-signal-unreadable'
        return $result
    }
}

function Reset-SpecrewBlockCount {
    param([string]$Path)
    try { if (Test-Path -LiteralPath $Path -PathType Leaf) { Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue } }
    catch { $null = $_ }
}

function Get-SpecrewFireIdentity {
    # A stable identity for THIS Stop fire = a hash of the recent transcript tail + the boundary cursor + the source
    # event. Two fires with the same identity are the SAME observable state (a duplicate hook delivery for the same
    # message); the boundary force-continue loop produces a NEW message each turn -> a different identity.
    param([string[]]$Parts)
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes(($Parts -join '|'))
        return (-join ([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }))
    }
    catch { return '' }
}

function Resolve-SpecrewBootstrapDir {
    # The scripts/internal/bootstrap dir (ConversationCaptureAccessor + ProjectMetadataAccessor). Direct candidates
    # (project tree, then SPECREW_MODULE_PATH) FIRST; the Get-Module -ListAvailable scan (slow over OneDrive /
    # multi-version) runs ONLY if they miss. $null if none resolves.
    param([string]$ProjectRoot)
    foreach ($base in @($ProjectRoot, $env:SPECREW_MODULE_PATH)) {
        if ([string]::IsNullOrWhiteSpace($base)) { continue }
        $bd = Join-Path $base 'scripts/internal/bootstrap'
        if (Test-Path -LiteralPath (Join-Path $bd 'ConversationCaptureAccessor.ps1') -PathType Leaf) { return $bd }
    }
    try {
        $mod = Get-Module -ListAvailable Specrew | Sort-Object Version -Descending |
            Where-Object { Test-Path -LiteralPath (Join-Path $_.ModuleBase 'scripts/internal/bootstrap/ConversationCaptureAccessor.ps1') } | Select-Object -First 1
        if ($mod) { return (Join-Path $mod.ModuleBase 'scripts/internal/bootstrap') }
    }
    catch { $null = $_ }
    return $null
}

function Test-SpecrewWorkshopInProgress {
    # FR-015: the design workshop's per-lens questions are the ONLY exclusion from the every-stop packet rule. The
    # lens workshop CONTINUES after create-new-feature.ps1 scaffolds spec.md, so a pre-spec proxy is WRONG (the
    # dogfood false-blocked a lens question once spec.md existed). REUSE the canonical Get-SpecrewWorkshopProgress
    # (lens-applicability.json `selected` + workshop/*.md done records): a feature with a confirmed lens agenda
    # (has_applicability) and lenses still REMAINING is mid-workshop. Returns $true ONLY on a positive, readable
    # detection (a real workshop state); any miss / read error -> $false (a missing signal does not fabricate a
    # workshop, so a genuine boundary still enforces). FR-008 reuse - not a parallel workshop-state inference.
    # SCOPED TO THE ACTIVE FEATURE (145 OB-1): check ONLY $FeatureRef - a DIFFERENT (abandoned) feature whose
    # workshop still has lenses remaining must NOT suppress the ACTIVE feature's enforcement (the old whole-project
    # loop let one stale feature silently disable every block for the active one - the exact #2884 gate-slip). An
    # unresolved $FeatureRef -> $false (do NOT suppress; fail toward enforcement, the safe direction).
    param([string]$ProjectRoot, [AllowNull()][string]$BootstrapDir, [AllowNull()][string]$FeatureRef)
    try {
        if ([string]::IsNullOrWhiteSpace($BootstrapDir) -or [string]::IsNullOrWhiteSpace($FeatureRef)) { return $false }
        $pma = Join-Path $BootstrapDir 'ProjectMetadataAccessor.ps1'
        if (-not (Test-Path -LiteralPath $pma -PathType Leaf)) { return $false }
        try { . $pma } catch { return $false }
        if (-not (Get-Command Get-SpecrewWorkshopProgress -ErrorAction SilentlyContinue)) { return $false }
        $wp = $null
        try { $wp = Get-SpecrewWorkshopProgress -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef } catch { $wp = $null }
        if ($null -ne $wp -and [bool]$wp.has_applicability -and (@($wp.remaining).Count -gt 0)) { return $true }
    }
    catch { $null = $_ }
    return $false
}

function Test-SpecrewWorkshopComplete {
    # Complements Test-SpecrewWorkshopInProgress for the pre-boundary scaffold suppression. A feature with no
    # active boundary/authorization can still have completed its lens workshop; in that case material work after
    # the workshop must not be suppressed as "initial intake".
    param([string]$ProjectRoot, [AllowNull()][string]$BootstrapDir, [AllowNull()][string]$FeatureRef)
    try {
        if ([string]::IsNullOrWhiteSpace($BootstrapDir) -or [string]::IsNullOrWhiteSpace($FeatureRef)) { return $false }
        $pma = Join-Path $BootstrapDir 'ProjectMetadataAccessor.ps1'
        if (-not (Test-Path -LiteralPath $pma -PathType Leaf)) { return $false }
        try { . $pma } catch { return $false }
        if (-not (Get-Command Get-SpecrewWorkshopProgress -ErrorAction SilentlyContinue)) { return $false }
        $wp = $null
        try { $wp = Get-SpecrewWorkshopProgress -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef } catch { $wp = $null }
        return ($null -ne $wp -and [bool]$wp.has_applicability -and (@($wp.selected).Count -gt 0) -and (@($wp.remaining).Count -eq 0))
    }
    catch { $null = $_ }
    return $false
}

# --- manual $args parse (the double-dash contract; B1 - NO param()) ---
$hostKindArg = $null
$sourceEventArg = $null
$transcriptPathArg = $null
for ($i = 0; $i -lt $args.Count; $i++) {
    if ($args[$i] -eq '--host-kind' -and ($i + 1) -lt $args.Count) { $hostKindArg = [string]$args[$i + 1] }
    elseif ($args[$i] -eq '--source-event' -and ($i + 1) -lt $args.Count) { $sourceEventArg = [string]$args[$i + 1] }
    elseif ($args[$i] -eq '--transcript-path' -and ($i + 1) -lt $args.Count) { $transcriptPathArg = [string]$args[$i + 1] }
}

try {
    $projectRoot = (Get-Location).Path
    if ([string]::IsNullOrWhiteSpace($projectRoot) -or -not (Test-Path -LiteralPath (Join-Path $projectRoot '.specrew'))) {
        return  # not a governed project root - nothing to check.
    }
    if (-not [string]::IsNullOrWhiteSpace($sourceEventArg) -and ($sourceEventArg.ToLowerInvariant() -notin @('stop', 'agentstop'))) {
        return  # only an end-of-turn Stop-class event (the registration already gates this; defensive).
    }

    # --- component resolution (fail-open: a component that cannot load simply disables its lane) ---
    # shared-governance.ps1 ships BESIDE this provider - the canonical Get-SpecrewPendingVerdictState + boundary order.
    $sgBeside = Join-Path $PSScriptRoot 'shared-governance.ps1'
    if (Test-Path -LiteralPath $sgBeside -PathType Leaf) { try { . $sgBeside } catch { $null = $_ } }

    # --- CHEAP signals first (no per-line transcript parse) ---
    # Pending-verdict state (the boundary trigger) - reused canonical helper; WARN loudly if it cannot load (F4).
    $pending = $null
    if (Get-Command Get-SpecrewPendingVerdictState -ErrorAction SilentlyContinue) {
        try { $pending = Get-SpecrewPendingVerdictState -ProjectRoot $projectRoot } catch { $pending = $null }
    }
    else {
        [Console]::Error.WriteLine('[specrew-conformance] WARN CONFORMANCE_DETECTOR_UNAVAILABLE shared-governance/Get-SpecrewPendingVerdictState did not load; the boundary lane is dark this stop (the gate STATE + resume surface remain the authority).')
    }
    $hasPending = ($null -ne $pending -and [bool]$pending.HasPendingVerdict)

    # Any feature spec on disk (cheap dir check) -> the substantial + #1 triggers need this.
    $anySpec = $false; $specPath = $null
    try {
        $specs = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'specs') -Directory -ErrorAction Stop |
            ForEach-Object { Join-Path $_.FullName 'spec.md' } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf })
        if ($specs.Count -gt 0) { $anySpec = $true; $specPath = $specs[0] }
    }
    catch { $anySpec = $false }

    # Active feature ref (145 OB-1): the workshop exclusion must scope to THIS feature, not the whole project.
    # session_state.feature_ref is canonical; fall back to the spec dir found above; null -> the exclusion fails
    # toward enforcement (it does not suppress a different feature's workshop onto the active one).
    $activeFeatureRef = $null
    $activeFeatureFromSessionState = $false
    $startContextReadable = $false
    $hasActiveLifecycleBoundary = $false
    $hasBoundaryAuthorization = $false
    try {
        $scPath = Join-Path $projectRoot '.specrew/start-context.json'
        if (Test-Path -LiteralPath $scPath -PathType Leaf) {
            $sc = Get-Content -LiteralPath $scPath -Raw -Encoding UTF8 | ConvertFrom-Json
            $startContextReadable = $true
            if ($sc.PSObject.Properties['session_state'] -and $null -ne $sc.session_state -and $sc.session_state.PSObject.Properties['feature_ref'] -and -not [string]::IsNullOrWhiteSpace([string]$sc.session_state.feature_ref)) {
                $activeFeatureRef = [string]$sc.session_state.feature_ref
                $activeFeatureFromSessionState = $true
            }
            if ($sc.PSObject.Properties['session_state'] -and $null -ne $sc.session_state -and $sc.session_state.PSObject.Properties['boundary_type'] -and -not [string]::IsNullOrWhiteSpace([string]$sc.session_state.boundary_type)) {
                $hasActiveLifecycleBoundary = $true
            }
            if ($sc.PSObject.Properties['boundary_enforcement'] -and $null -ne $sc.boundary_enforcement) {
                if ($sc.boundary_enforcement.PSObject.Properties['last_authorized_boundary'] -and -not [string]::IsNullOrWhiteSpace([string]$sc.boundary_enforcement.last_authorized_boundary)) {
                    $hasBoundaryAuthorization = $true
                }
                if ($sc.boundary_enforcement.PSObject.Properties['verdict_history'] -and @($sc.boundary_enforcement.verdict_history).Count -gt 0) {
                    $hasBoundaryAuthorization = $true
                }
            }
        }
    }
    catch { $null = $_ }
    $bootstrapDir = Resolve-SpecrewBootstrapDir -ProjectRoot $projectRoot

    # If lifecycle state is still pre-boundary / anchorless, the rolling Stop handover is the fresher scoped signal.
    # In a multi-feature repo, falling back to the first specs/* directory can incorrectly borrow an abandoned
    # feature's workshop state and suppress a real material-work packet for the active feature.
    $materialSignal = Get-SpecrewCurrentStopMaterialSignal -ProjectRoot $projectRoot -BootstrapDir $bootstrapDir
    if (-not $activeFeatureFromSessionState -and $null -ne $materialSignal -and -not [string]::IsNullOrWhiteSpace([string]$materialSignal.active_feature)) {
        $activeFeatureRef = [string]$materialSignal.active_feature
    }
    if ([string]::IsNullOrWhiteSpace($activeFeatureRef) -and -not [string]::IsNullOrWhiteSpace($specPath)) {
        $activeFeatureRef = Split-Path (Split-Path $specPath -Parent) -Leaf
    }
    $preBoundaryWorkshopCandidate = $startContextReadable -and (-not $hasActiveLifecycleBoundary) -and (-not $hasBoundaryAuthorization)
    $workshopComplete = $false
    if ($preBoundaryWorkshopCandidate) {
        $workshopComplete = Test-SpecrewWorkshopComplete -ProjectRoot $projectRoot -BootstrapDir $bootstrapDir -FeatureRef $activeFeatureRef
    }
    $preBoundaryWorkshop = $preBoundaryWorkshopCandidate -and (-not $workshopComplete)

    # #3 RAW SPEC KIT - a CHEAP raw-text scan of the recent tail (NO per-line JSON parse). NEGATION GUARD: skip a
    # match whose preceding context is a prohibition / quote (the contract's OWN "do NOT run the raw `specify.exe
    # workflow`" prose) so it does not false-fire (dogfood + 145 fix-followup). Also suppressed in-workshop below.
    $rawHit = $false
    $rawTail = ''
    if (-not [string]::IsNullOrWhiteSpace($transcriptPathArg) -and (Test-Path -LiteralPath $transcriptPathArg -PathType Leaf)) {
        try {
            $rawTail = (@(Get-Content -LiteralPath $transcriptPathArg -Tail 40 -Encoding UTF8 -ErrorAction Stop) -join "`n")
            foreach ($mm in ([regex]::new('(?i)\bspecify(?:\.exe)?\s+workflow\b')).Matches($rawTail)) {
                $pre = $rawTail.Substring([Math]::Max(0, $mm.Index - 24), [Math]::Min(24, $mm.Index))
                if ($pre.Contains([char]96) -or ($pre -match '(?i)\b(not|never|raw|un|forbidden|avoid|don)\b')) { continue }  # prohibition/quote prose, not an invocation
                $rawHit = $true; break
            }
        }
        catch { $null = $_ }
    }

    # Material-work lane (Proposal 145 follow-up): the old length-only "substantial" trigger over-blocked normal
    # discussion, so use the deterministic rolling-handover signal written by the preceding Stop provider instead.
    $materialStop = ($null -ne $materialSignal -and [bool]$materialSignal.material)
    $blockStatePath = Join-Path $projectRoot '.specrew/runtime/conformance-stop-block.json'
    $materialSatisfiedPath = Join-Path $projectRoot '.specrew/runtime/conformance-material-satisfied.json'
    $existingBlockRecord = Get-SpecrewBlockRecord -Path $blockStatePath
    $materialRetryKey = Get-SpecrewRecentMaterialRetryKey -Record $existingBlockRecord
    $materialSatisfiedKey = Get-SpecrewMaterialSatisfiedKey -Path $materialSatisfiedPath
    if ($preBoundaryWorkshop) {
        # Feature scaffolding during initial lens intake is workshop material, not a within-phase hand-back.
        $materialStop = $false
        $materialRetryKey = $null
    }

    # (IDEMPOTENCY check is performed BELOW - after the role-aware last-assistant message + the workshop/marker state
    # are computed - so the fire-identity captures the FULL decision-relevant state. An EARLY tail-40 identity falsely
    # deduped a genuine second boundary stop when the distinguishing message fell outside tail-40, or across a
    # workshop-completion flip; 145 IDEMP-1 / SC-1.)

    # --- WORKSHOP EXCLUSION (FR-015): the design workshop's per-lens questions are the ONLY exclusion from the
    # every-stop packet rule. The lens workshop CONTINUES after create-new-feature.ps1 scaffolds spec.md, so the old
    # pre-spec proxy false-blocked a lens question once spec.md existed (dogfood). Detect the workshop ROBUSTLY (the
    # reused Get-SpecrewWorkshopProgress: a confirmed lens agenda with lenses still remaining) and, while in it,
    # SUPPRESS every signal (no block, no #1, no #3). ---
    $inWorkshop = $false
    if ($hasPending -or $anySpec -or $rawHit -or $materialStop) {
        if ([string]::IsNullOrWhiteSpace($bootstrapDir)) { $bootstrapDir = Resolve-SpecrewBootstrapDir -ProjectRoot $projectRoot }
        $inWorkshop = Test-SpecrewWorkshopInProgress -ProjectRoot $projectRoot -BootstrapDir $bootstrapDir -FeatureRef $activeFeatureRef
    }
    if ($inWorkshop) { $rawHit = $false }  # a workshop lens question owes no packet and is not a raw-Spec-Kit deviation.

    # --- EXPENSIVE transcript parse ONLY on a MATERIAL-TURN stop (T099/FR-040, design N3): the per-line
    # ConvertFrom-Json parse is the dominant Stop-hook cost and scales with session size. It runs ONLY when
    # the stop actually followed material work (the deterministic rolling-handover signal), a boundary is
    # pending, or a material forced-continue retry is in flight - a trivial/conversational stop skips it
    # entirely. The old `$anySpec` trigger made EVERY stop in EVERY real project pay the parse just to feed
    # the #1 intake regex; that check now only evaluates on the stops that already warranted the parse
    # (an idle intake drift is caught by the bootstrap orientation surface instead). ---
    $lastAssistantText = $null; $intakeHit = $false; $ccLoaded = $false; $markerForPendingCrossing = $false
    $pendingCrossing = $null
    if ($hasPending -and (Get-Command Get-SpecrewPendingBoundaryCrossing -ErrorAction SilentlyContinue)) {
        try { $pendingCrossing = Get-SpecrewPendingBoundaryCrossing -LastAuthorizedBoundary ([string]$pending.LastAuthorizedBoundary) -WorkingBoundary ([string]$pending.WorkingBoundary) } catch { $pendingCrossing = $null }
    }
    if (($hasPending -or $materialStop -or -not [string]::IsNullOrWhiteSpace($materialRetryKey)) -and (-not $inWorkshop)) {
        if ([string]::IsNullOrWhiteSpace($bootstrapDir)) { $bootstrapDir = Resolve-SpecrewBootstrapDir -ProjectRoot $projectRoot }
        if (-not [string]::IsNullOrWhiteSpace($bootstrapDir)) {
            $cc = Join-Path $bootstrapDir 'ConversationCaptureAccessor.ps1'
            if (Test-Path -LiteralPath $cc -PathType Leaf) { try { . $cc; $ccLoaded = $true } catch { $null = $_ } }
        }
        if ($ccLoaded -and -not [string]::IsNullOrWhiteSpace($transcriptPathArg) -and (Test-Path -LiteralPath $transcriptPathArg -PathType Leaf) -and
            (Get-Command Get-SpecrewConversationTurnFromLine -ErrorAction SilentlyContinue)) {
            try {
                $tail = @(Get-Content -LiteralPath $transcriptPathArg -Tail 200 -Encoding UTF8 -ErrorAction Stop)
                for ($k = $tail.Count - 1; $k -ge 0; $k--) {
                    $turn = Get-SpecrewConversationTurnFromLine -Line $tail[$k]
                    if ($null -ne $turn -and [string]$turn.role -eq 'assistant' -and -not [string]::IsNullOrWhiteSpace([string]$turn.text)) { $lastAssistantText = [string]$turn.text; break }
                }
            }
            catch { $lastAssistantText = $null }
        }
        # #1 intake question (needs the role-aware last assistant text + a spec on disk).
        if ($anySpec -and -not [string]::IsNullOrWhiteSpace($lastAssistantText)) {
            $intakeRx = [regex]::new('(?i)\bwhat\b[^.?!]{0,60}\b(?:do you want|would you like|are you looking|should we|are we|can i help you)\b[^.?!]{0,40}\b(?:build|create|make|work on)\b|(?i)\bwhat\b[^.?!]{0,40}\b(?:feature|app|project|product)\b[^.?!]{0,40}\b(?:build|create|want|like)\b|(?i)\bwhat (?:do you want|would you like) to build\b')
            if ($intakeRx.IsMatch($lastAssistantText)) { $intakeHit = $true }
        }
        # BOUNDARY VERDICT MARKER (Antigravity dogfood gap): at a boundary the six-section HEADERS alone do NOT
        # authorize the crossing - the <!-- SPECREW-VERDICT-BOUNDARY --> marker is what captures the verdict. A weak
        # host rendered the headers but NOT the marker, so the verdict was never captured (last_authorized stayed
        # none) yet the header check suppressed the block. So at a boundary, suppress ONLY when the marker for the
        # PENDING crossing is present; headers without that marker still block.
        if ($hasPending -and $ccLoaded -and (Get-Command Get-SpecrewCapturedBoundaryPacket -ErrorAction SilentlyContinue)) {
            try {
                $pkt = Get-SpecrewCapturedBoundaryPacket -TranscriptPath $transcriptPathArg
                if ($null -ne $pkt -and [bool]$pkt.Found -and $null -ne $pendingCrossing -and [bool]$pendingCrossing.HasPendingVerdict) {
                    $pktFrom = Normalize-SpecrewCanonicalBoundaryType -Boundary ([string]$pkt.FromBoundary)
                    $pktTo = Normalize-SpecrewCanonicalBoundaryType -Boundary ([string]$pkt.ToBoundary)
                    $expectedFrom = Normalize-SpecrewCanonicalBoundaryType -Boundary ([string]$pendingCrossing.PendingFromMarkerBoundary)
                    $expectedTo = Normalize-SpecrewCanonicalBoundaryType -Boundary ([string]$pendingCrossing.PendingToMarkerBoundary)
                    if (-not [string]::IsNullOrWhiteSpace($pktTo) -and $pktFrom -eq $expectedFrom -and $pktTo -eq $expectedTo) { $markerForPendingCrossing = $true }
                }
            }
            catch { $null = $_ }
        }
    }
    $packetPresent = Test-SpecrewReentryPacketPresent -Text $lastAssistantText
    # ISSUE-2 PERF REVERT: the flush/read-race RE-READ (4x tail-200 parse, ~17s on a large transcript) is REMOVED.
    # It was an UNCONFIRMED mitigation (the instrumented false-negative never reproduced) and it taxed every
    # material stop AND starved the navigator (order 50) of the shared 20s Stop budget, so co-review stopped firing.
    # If the flush-race double-render ever reproduces WITH a captured dx_ record, re-add a CHEAP variant (a tiny
    # last-line re-read, not a full 200-line parse). The dx_* journal keeps the decision observable in the meantime.
    $substantial = (-not [string]::IsNullOrWhiteSpace($lastAssistantText)) -and ($lastAssistantText.Length -ge $script:SpecrewSubstantialChars)

    # --- IDEMPOTENCY (duplicate-fire guard, 145 IDEMP-1 / SC-1): dedup a re-fired hook for the SAME observable DECISION
    # state, processed ONCE. The identity uses the ROLE-AWARE last-assistant message (the SAME view the block decision
    # reads - NOT a coarse tail-40 that collides when the distinguishing message is >40 entries back) PLUS the boundary
    # cursor and the marker / workshop / pending discriminators - so two genuinely-different stops, or a
    # workshop-completion flip, get DIFFERENT identities and are NOT falsely deduped (the dangerous missed-enforcement
    # direction). Computed AFTER those signals exist. Best-effort + fail-open: a read/write miss just disables dedup,
    # never blocks the stop. The force-continue loop is unaffected (each forced re-render is a NEW message).
    $idWorking = if ($null -ne $pending) { [string]$pending.WorkingBoundary } else { '' }
    $idAuth = if ($null -ne $pending) { [string]$pending.LastAuthorizedBoundary } else { '' }
    $fireIdentity = Get-SpecrewFireIdentity -Parts @([string]$lastAssistantText, $idWorking, $idAuth, ("m={0}" -f [int][bool]$markerForPendingCrossing), ("w={0}" -f [int][bool]$inWorkshop), ("pbw={0}" -f [int][bool]$preBoundaryWorkshop), ("p={0}" -f [int][bool]$hasPending), ("mat={0}" -f [string]$materialSignal.key), ("mr={0}" -f [string]$materialRetryKey), [string]$sourceEventArg)
    $lastFirePath = Join-Path $projectRoot '.specrew/runtime/conformance-last-fire.json'
    if (-not [string]::IsNullOrWhiteSpace($fireIdentity)) {
        try {
            if (Test-Path -LiteralPath $lastFirePath -PathType Leaf) {
                $lf = Get-Content -LiteralPath $lastFirePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
                if (($lf.PSObject.Properties.Name -contains 'identity') -and ([string]$lf.identity -eq $fireIdentity) -and ($lf.PSObject.Properties.Name -contains 'epoch')) {
                    $age = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - [long]$lf.epoch
                    if ($age -ge 0 -and $age -le $script:SpecrewFireDedupWindowSec) { return }  # duplicate fire -> idempotent no-op
                }
            }
        }
        catch { $null = $_ }
        try {
            $rdir = Split-Path -Parent $lastFirePath
            if ($rdir -and -not (Test-Path -LiteralPath $rdir)) { New-Item -ItemType Directory -Path $rdir -Force | Out-Null }
            ([pscustomobject]@{ identity = $fireIdentity; epoch = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } | ConvertTo-Json -Compress) | Set-Content -LiteralPath $lastFirePath -Encoding UTF8
        }
        catch { $null = $_ }
    }

    # --- block decision: does this stop owe the packet, and is the packet absent? ---
    # BOUNDARY stops (HasPendingVerdict) owe the packet regardless of the workshop proxy - a pending verdict means a
    # boundary was already crossed, so we are inherently PAST intake. The SUBSTANTIAL non-boundary trigger is gated on
    # a spec existing ($anySpec = past intake), which excludes the pre-spec design-workshop window; the design-analysis
    # lens workshop AFTER spec.md is a documented residual (dogfood-tunable).
    # FIX C (145 F1-CC-FAIL-CLOSED): only block when we ACTUALLY READ the last assistant message - we cannot claim
    # "the packet is absent" without reading it. If ConversationCaptureAccessor did not load (stale install) or there
    # is no transcript, $lastAssistantText is null -> do NOT block (fail-open, matching the Get-SpecrewPendingVerdictState
    # fail-open; never block a correctly-rendered packet we simply could not see, and never go fail-CLOSED on a missing
    # component). This is the same failure-class -> same direction (allow) as the boundary-trigger load failure above.
    $canAssess = -not [string]::IsNullOrWhiteSpace($lastAssistantText)
    # BOUNDARY stop: owes the verdict MARKER (not just the six headers) - the marker is what captures the verdict;
    # headers WITHOUT it leave the gate un-authorized (the Antigravity dogfood: a packet rendered, no marker,
    # last_authorized stayed `none`). $markerForPendingCrossing also subsumes the old false-positive guard (a
    # captured marker for THIS crossing = a legitimate awaiting-verdict stop, 145 TI-2/F1).
    # NON-BOUNDARY material hand-back: the packet headers suffice (a within-phase stop has no verdict marker).
    # FIX C (145 F1-CC): $canAssess gates both - we never claim "absent" without reading the message (fail-open).
    # Hard-block ONLY genuine DECISION-YIELD stops = a BOUNDARY (pending verdict + missing marker). The earlier
    # "substantial" (>=600-char) non-boundary trigger was DROPPED (maintainer 2026-06-21): a long but communicative
    # DISCUSSION / status answer is not a decision-yield and must not be force-blocked into a packet. The replacement
    # hard block is deterministic material work only: the current Stop handover reports changed user files or new
    # commits. Once a packet has been rendered for the same material surface, later quick discussion while the tree
    # stays dirty is allowed; a changed material surface requires a fresh packet.
    $boundaryBlock = $hasPending -and (-not $markerForPendingCrossing)
    $materialAlreadySatisfied = $materialStop -and (-not [string]::IsNullOrWhiteSpace([string]$materialSignal.key)) -and ([string]$materialSignal.key -eq [string]$materialSatisfiedKey)
    $materialInitialBlock = (-not $hasPending) -and $materialStop -and (-not $packetPresent) -and (-not $materialAlreadySatisfied)
    $materialRetryBlock = (-not $hasPending) -and (-not [string]::IsNullOrWhiteSpace($materialRetryKey)) -and (-not $packetPresent)
    $materialBlock = $materialInitialBlock -or $materialRetryBlock
    $blockKind = if ($boundaryBlock) { 'boundary' } elseif ($materialBlock) { 'material' } else { 'none' }
    $blockWarranted = $canAssess -and ($blockKind -ne 'none')

    $journalPath = Join-Path $projectRoot '.specrew/runtime/conformance-journal.jsonl'
    $blockReason = $null
    $corrections = New-Object System.Collections.Generic.List[string]
    $capped = $false
    $cappedKind = $null
    # The advance identity the consecutive-block cap keys on: a boundary advance is working|lastAuth; a material
    # non-boundary stop is keyed by the current handover snapshot. A NEW advance/snapshot starts a fresh count; the agent
    # rendering the packet (not blockWarranted) resets it. No time window (145 HANG-1).
    # The if-guard also protects the $pending null-deref under the leaked StrictMode; the else value is an unused
    # placeholder ($advanceKey is read only inside the blockWarranted branch, which implies $hasPending; 145 SC-3).
    $advanceKey = if ($blockKind -eq 'boundary' -and $hasPending) {
        ("{0}|{1}" -f [string]$pending.WorkingBoundary, [string]$pending.LastAuthorizedBoundary)
    }
    elseif ($blockKind -eq 'material' -and $materialInitialBlock -and -not [string]::IsNullOrWhiteSpace([string]$materialSignal.key)) {
        [string]$materialSignal.key
    }
    elseif ($blockKind -eq 'material' -and -not [string]::IsNullOrWhiteSpace($materialRetryKey)) {
        [string]$materialRetryKey
    }
    else { 'na' }

    if ($blockWarranted) {
        $count = Get-SpecrewBlockCount -Path $blockStatePath -Key $advanceKey
        if ($count -ge $script:SpecrewBlockCap) {
            # Over the consecutive-block cap - stop blocking to avoid a hang; degrade to a plain nudge this turn.
            $capped = $true
            $cappedKind = $blockKind
            $capSubject = if ($blockKind -eq 'material') { 'material-work packet' } else { 'verdict marker' }
            [Console]::Error.WriteLine(("[specrew-conformance] WARN STOP_BLOCK_CAP {0} still absent or wrong after {1} consecutive blocks; releasing the stop (degrading to a nudge) to avoid a hang." -f $capSubject, $count))
        }
        elseif (Set-SpecrewBlockCount -Path $blockStatePath -Key $advanceKey -Count ($count + 1)) {
            # Block ONLY when the increment durably persisted (145 HANG-2): a host without a built-in cap relies on
            # this counter, so an unverifiable write must NOT start an uncappable loop.
            # Build the packet directive. At a boundary, include the CONTIGUOUS last_authorized -> successor marker.
            $sb = New-Object System.Text.StringBuilder
            if ($blockKind -eq 'boundary') {
                [void]$sb.AppendLine('Specrew: boundary state is pending, but your last message did not expose the verdict marker for the pending boundary crossing. Render the full six-section re-entry packet NOW as your message, then stop again:')
                [void]$sb.AppendLine('## What I Just Did / ## Why I Stopped / ## What Needs Your Review / ## What Happens Next / ## Discussion Prompts / ## What I Need From You')
                [void]$sb.AppendLine('Every artifact reference uses a bare file:/// URL.')
                $fromBoundary = if ($null -ne $pendingCrossing -and [bool]$pendingCrossing.HasPendingVerdict) { [string]$pendingCrossing.PendingFromMarkerBoundary } else { $null }
                $toBoundary = if ($null -ne $pendingCrossing -and [bool]$pendingCrossing.HasPendingVerdict) { [string]$pendingCrossing.PendingToMarkerBoundary } else { [string]$pending.WorkingBoundary }
                [void]$sb.AppendLine('')
                [void]$sb.AppendLine([string]$pending.Message)
                if (-not [string]::IsNullOrWhiteSpace($fromBoundary)) {
                    [void]$sb.AppendLine(("This is a BOUNDARY stop ({0} -> {1}); emit the verdict marker as the LAST line: <!-- SPECREW-VERDICT-BOUNDARY: {0} -> {1} -->" -f $fromBoundary, $toBoundary))
                }
                else {
                    [void]$sb.AppendLine(("This is a BOUNDARY stop into '{0}' (the first unauthorized boundary); emit the contiguous verdict marker as the LAST line." -f $toBoundary))
                }
                [void]$sb.AppendLine('Do NOT record the authorization yourself; the verdict is captured from your rendered packet + the human''s reply.')
            }
            elseif ($blockKind -eq 'material') {
                [void]$sb.AppendLine('Specrew: this Stop followed material work, but your last message did not render the required non-boundary context packet. Render the five-part context packet NOW as your message, then stop again:')
                [void]$sb.AppendLine('## What I Just Did / ## Why I Stopped / ## What Needs Your Review / ## What Happens Next / ## What I Need From You')
                [void]$sb.AppendLine('Every artifact reference uses a bare file:/// URL.')
                [void]$sb.AppendLine('This is a NON-BOUNDARY material-work stop; do NOT emit a SPECREW-VERDICT-BOUNDARY marker.')
            }
            if ($intakeHit) { [void]$sb.AppendLine('Also: an active feature already exists - do NOT ask what to build; continue it.') }
            if ($rawHit) { [void]$sb.AppendLine('Also: do NOT run the raw `specify workflow` SDD engine - route through the governed Specrew flow.') }
            $blockReason = $sb.ToString().TrimEnd()
        }
        else {
            # The counter increment could not be persisted/verified -> the cap cannot be guaranteed on a capless
            # host -> do NOT block (fail-open, 145 HANG-2). A hang with no diagnostic is the worst outcome, so WARN.
            [Console]::Error.WriteLine('[specrew-conformance] WARN STOP_BLOCK_COUNTER_UNWRITABLE cannot persist the loop-guard counter; releasing the stop (no block) to stay fail-open.')
        }
    }
    else {
        # Packet present, or nothing owed -> the agent complied; clear the loop-guard counter.
        if ($materialStop -and $packetPresent -and -not [string]::IsNullOrWhiteSpace([string]$materialSignal.key)) {
            Set-SpecrewMaterialSatisfiedKey -Path $materialSatisfiedPath -Key ([string]$materialSignal.key)
        }
        Reset-SpecrewBlockCount -Path $blockStatePath
    }

    # If not blocking (not warranted, or capped), surface the cooperative nudges instead.
    if ([string]::IsNullOrWhiteSpace($blockReason)) {
        if ($capped) {
            if ($cappedKind -eq 'material') {
                $corrections.Add('[specrew-conformance] MATERIAL-WORK STOP packet still missing (FR-015) - render the five-part context packet with file:/// references before handing control back.') | Out-Null
            }
            else {
                $corrections.Add('[specrew-conformance] BOUNDARY VERDICT MARKER still missing or wrong (FR-011/FR-015) - render the six-section packet and emit the exact pending-crossing SPECREW-VERDICT-BOUNDARY marker so the human verdict can be captured.') | Out-Null
            }
        }
        if ($intakeHit) { $corrections.Add(("[specrew-conformance] INTAKE QUESTION while an active feature exists (FR-011 #1)`n`nYou asked the human what to build, but a feature is already in flight (spec exists at {0}). Do NOT restart intake - read it and continue the active feature." -f $specPath)) | Out-Null }
        if ($rawHit) { $corrections.Add("[specrew-conformance] RAW SPEC KIT invocation detected (FR-011 #3)`n`nDo NOT run the un-governed 'specify workflow' automation - route through the Specrew design workshop and the governed /speckit.* commands so the gates are honored.") | Out-Null }
    }

    # --- forensic journal (diagnostics only - never gate state) ---
    # Also record EVERY material stop (not only blocks) so a spurious material block is diagnosable against the
    # passing case (D-197-I009 conformance false-negative: a valid packet on disk still evaluated packetPresent=false).
    if (-not [string]::IsNullOrWhiteSpace($blockReason) -or $capped -or $intakeHit -or $rawHit -or $materialStop) {
        try {
            $jdir = Split-Path -Parent $journalPath
            if ($jdir -and -not (Test-Path -LiteralPath $jdir)) { New-Item -ItemType Directory -Path $jdir -Force | Out-Null }
            $evt = if (-not [string]::IsNullOrWhiteSpace($blockReason)) { 'stop-block' } elseif ($capped) { 'stop-block-capped' } elseif ($intakeHit -or $rawHit) { 'nudge' } else { 'observe' }
            $jWorking = if ($null -ne $pending) { [string]$pending.WorkingBoundary } else { '' }
            $jAuth = if ($null -ne $pending) { [string]$pending.LastAuthorizedBoundary } else { '' }
            # dx_* = the actual inputs to the packetPresent decision, so a wrong block is no longer silent.
            $diagLat = [string]$lastAssistantText
            $diagHits = 0; foreach ($dh in $script:SpecrewReentryHeaders) { if (-not [string]::IsNullOrEmpty($diagLat) -and $diagLat -match [regex]::Escape($dh)) { $diagHits++ } }
            # NO content snippet is recorded: dx_lat_len + dx_lat_hits diagnose a false-negative (hits<4 = the
            # packet was not seen; len distinguishes a short stale message from the long packet) WITHOUT writing
            # any conversation text to the (local, git-ignored) journal. Maintainer privacy call 2026-06-28.
            $rec = [pscustomobject]@{ event = $evt; recorded_at = (Get-Date).ToUniversalTime().ToString('o'); has_pending = $hasPending; working = $jWorking; last_authorized = $jAuth; substantial = $substantial; material = $materialStop; block_kind = $blockKind; intake = $intakeHit; raw = $rawHit; host = $hostKindArg; source = $sourceEventArg; dx_transcript_arg = (-not [string]::IsNullOrWhiteSpace($transcriptPathArg)); dx_transcript_exists = ((-not [string]::IsNullOrWhiteSpace($transcriptPathArg)) -and (Test-Path -LiteralPath $transcriptPathArg -PathType Leaf)); dx_cc_loaded = $ccLoaded; dx_lat_len = $diagLat.Length; dx_lat_hits = $diagHits; dx_packet_present = $packetPresent; dx_material_retry = (-not [string]::IsNullOrWhiteSpace($materialRetryKey)) }
            ($rec | ConvertTo-Json -Compress) | Add-Content -LiteralPath $journalPath -Encoding UTF8
        }
        catch { $null = $_ }
    }

    # --- emit: a block sentinel (the dispatcher force-continues), else the plain inject nudges, else nothing ---
    if (-not [string]::IsNullOrWhiteSpace($blockReason)) {
        Write-Output ("<<<SPECREW-STOP-BLOCK>>>`n" + $blockReason)
    }
    elseif ($corrections.Count -gt 0) {
        Write-Output ($corrections.ToArray() -join "`n`n")
    }
    return
}
catch {
    [Console]::Error.WriteLine("[specrew-conformance] WARN CONFORMANCE_PROVIDER_FAILED $($_.Exception.Message)")
    return
}