<#
.SYNOPSIS
  governance-detectors.ps1 - Harness-11 PHAN C + B3 governance drift detectors.

.DESCRIPTION
  NO-API, DETECT-and-FLAG-only grep net (Harness-11 mandate):
    (1) NO-API   - only Select-String + byte/file-exist measure. NEVER calls model/API.
    (2) FLAG-only - prints FLAGs, NEVER edits files (auto-WRITE of rules = top hazard, forbidden).
    (3) PowerShell 5.1 compatible. Run offline. ASCII-only script body (gotcha #30);
        target-file content is read -Encoding UTF8 so Vietnamese count-tokens
        (bay / bang / Du tru) match correctly.
    (5) DETECT-only LOWERING NET, not a hard build gate. Exit code always 0.

  Detectors:
    C2/B3 - derived-staleness : canonical counts from STATUS.md (cross-checked vs disk),
                                then derived docs scanned for stale count-tokens.
    C1    - broken-pointer    : (a) gotcha #N refs > max-gotcha or missing "### N." anchor
                                (b) dangling [[wikilink]] in user-memory / agent-memory.
    C3    - vocab-fork        : alias-sets where >=2 variants live side-by-side.
    C4    - self-line exclusion: pattern-describing files removed from every scan
                                 (else the detector self-matches).
    H24-1 - title-freshness   : doc's OWN title/status anchor date vs the newest
                                governance milestone date. ANCHOR-SCOPED parse.
    H24-2 - carry-age         : [carry:<slug>] keys alive across >= M consecutive
                                most-recent carry-lines. M read from config, never
                                hardcoded. INFORM-only.

  Each FLAG line:
    [DETECTOR] severity | file:line | description | resolve: <un-flag condition>   (C5)

.PARAMETER RepoRoot
  Repo root. Default = resolved 2 levels up from this script (scripts/ -> repo root).

.EXAMPLE
  powershell.exe -ExecutionPolicy Bypass -File scripts/governance-detectors.ps1
#>
param(
    [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
)

$ErrorActionPreference = 'Continue'

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
$script:FlagCount = 0

function Write-Flag {
    param(
        [ValidateSet('HIGH', 'MED', 'LOW')] [string]$Severity,
        [string]$Where,        # file:line
        [string]$Desc,
        [string]$Resolve
    )
    $color = switch ($Severity) { 'HIGH' { 'Red' } 'MED' { 'Yellow' } default { 'Gray' } }
    Write-Host ("[DETECTOR] {0,-4} | {1} | {2} | resolve: {3}" -f $Severity, $Where, $Desc, $Resolve) -ForegroundColor $color
    $script:FlagCount++
}

function Write-Section($title) {
    Write-Host ''
    Write-Host ("===== $title =====") -ForegroundColor Cyan
}

# INFORM-only flag sink for NEW detectors (C6 cite-2-tier, H24-4 pending-flip).
# Counted SEPARATELY in $script:InformCount and NEVER folded into $script:FlagCount
# (= the baseline TOTAL). Rationale (anti-Goodhart, owner-set): a brand-new net
# whose LOW hits are read by judgement on day one must not move the audited TOTAL
# down/up -- folding it would let "flag count fell" masquerade as progress and would
# hide whether the thing that changed was a false positive or a real witness. Fold +
# severity-raise is a POST-triage, owner-gated decision (see detector headers). Same
# [DETECTOR] line shape as Write-Flag so `comm before/after` cleanly isolates new lines.
$script:InformCount = 0
function Write-InformFlag {
    param(
        [string]$Where,        # file:line
        [string]$Desc,
        [string]$Resolve
    )
    Write-Host ("[DETECTOR] {0,-4} | {1} | {2} | resolve: {3}" -f 'LOW', $Where, $Desc, $Resolve) -ForegroundColor Gray
    $script:InformCount++
}

# Make a path repo-relative for readable FLAG output (forward slashes).
function Rel($full) {
    $r = $full
    if ($full.StartsWith($RepoRoot, [StringComparison]::OrdinalIgnoreCase)) {
        $r = $full.Substring($RepoRoot.Length).TrimStart('\', '/')
    }
    return ($r -replace '\\', '/')
}

# ---------------------------------------------------------------------------
# Unicode-token builder (gotcha #30 mojibake guard).
# This .ps1 is ASCII-only on disk. PowerShell 5.1 decodes a BOM-less .ps1 with
# the system ANSI codepage (NOT UTF-8) when launched via -File, which corrupts
# any inline Vietnamese literal (e.g. "bay" -> mojibake) so it can no longer
# match correctly-decoded UTF-8 file content. We therefore build every
# Vietnamese token from Unicode code points at RUNTIME -> encoding-independent.
function U { param([int[]]$cp) -join ($cp | ForEach-Object { [char]$_ }) }

# Vietnamese tokens used by detectors:
$VN_BAY  = U @(0x62, 0x1EAB, 0x79)                                  # "bay" (gotcha synonym)
$VN_BANG = U @(0x62, 0x1EA3, 0x6E, 0x67)                            # "bang" (table synonym)
$VN_DUTRU_PRO  = U @(0x44, 0x1EF1, 0x20, 0x74, 0x72, 0xF9, 0x20, 0x50, 0x52, 0x4F)             # "Du tru PRO"
$VN_NGANSACH_PRO = U @(0x4E, 0x67, 0xE2, 0x6E, 0x20, 0x73, 0xE1, 0x63, 0x68, 0x20, 0x50, 0x52, 0x4F) # "Ngan sach PRO"

# H24-1: EM DASH U+2014 (NOT hyphen-minus U+002D). Verified byte-level against
# .claude/skills/permission-matrix/SKILL.md:16 -> bytes "e2 80 94" = U+2014.
# Same runtime code-point trick as the VN tokens: an inline em-dash would be a
# non-ASCII byte in this .ps1 and would mojibake under the ANSI codepage decode.
$EM_DASH = U @(0x2014)

# ---------------------------------------------------------------------------
# C4 - self-line exclusion (BUILT FIRST so every scan can apply it)
# These files DESCRIBE the patterns the detectors look for; without exclusion
# the detector would flag itself. Glob-style suffix/substring rules.
# ---------------------------------------------------------------------------
$ExcludeExact = @(
    (Join-Path $RepoRoot 'scripts\governance-detectors.ps1'),
    (Join-Path $RepoRoot 'docs\governance\harness-11-engine.md'),
    (Join-Path $RepoRoot 'docs\governance\vocab-alias-map.md')
) | ForEach-Object { $_ -replace '/', '\' }

$ExcludeDirFragments = @(
    '\broadcasts\inbox\',
    '\broadcasts\outbox\',
    '\.claude\workflows\runs\',
    '\.claude\workflows\scripts\'
)

function Test-Excluded($full) {
    $p = ($full -replace '/', '\')
    foreach ($ex in $ExcludeExact) { if ($p -ieq $ex) { return $true } }
    foreach ($frag in $ExcludeDirFragments) { if ($p -ilike "*$frag*") { return $true } }
    return $false
}

# Resolve which excluded paths actually exist on disk (for the audit line).
$ExcludedActual = @()
foreach ($ex in $ExcludeExact) { if (Test-Path $ex) { $ExcludedActual += $ex } }
foreach ($frag in $ExcludeDirFragments) {
    $probe = Join-Path $RepoRoot ($frag.Trim('\'))
    if (Test-Path $probe) { $ExcludedActual += $probe }
}

# Gather governance MD set ONCE (docs/** + .claude/** *.md), minus excluded.
# VENDOR-SKIP (S123 review): this walks the FILESYSTEM, so it does NOT honour .gitignore.
# Measured: docs/_user-guide/node_modules/ holds 30 gitignored .md that were being scanned
# as if they were governance docs. Zero anchors in them today => zero impact so far, but
# H24-1 sets moc-phai to the MAX anchor in the corpus: ONE vendor README carrying a future
# "**Last updated:** 2027-xx-xx" would push the right edge past every real doc and silence
# the detector for good -- the exact strangle documented at the H24-1 banner below.
# The '(bin|obj|node_modules)' filter already existed further down for the code-scan; it
# was simply never applied here. Same class, one site patched, sibling missed -- which is
# the S123 lesson in miniature: patch a class, then grep every same-class site.
function Get-GovernanceMd {
    $dirs = @((Join-Path $RepoRoot 'docs'), (Join-Path $RepoRoot '.claude'))
    $all = @()
    foreach ($d in $dirs) {
        if (Test-Path $d) {
            $all += Get-ChildItem -Path $d -Recurse -Filter *.md -File -ErrorAction SilentlyContinue
        }
    }
    return $all |
        Where-Object { $_.FullName -notmatch '[\\/](bin|obj|node_modules)[\\/]' } |
        Where-Object { -not (Test-Excluded $_.FullName) }
}

$GovMd = Get-GovernanceMd

# ---------------------------------------------------------------------------
# Canonical values from docs/STATUS.md  +  disk cross-check
# ---------------------------------------------------------------------------
function Get-StatusValue {
    param([string]$StatusPath, [string]$RowLabel)
    # Match a CURRENT-STATE table row:  | <label> | **<number>** |
    $pat = '^\|\s*' + [regex]::Escape($RowLabel) + '\s*\|\s*\*\*(\d+)'
    $m = Select-String -Path $StatusPath -Pattern $pat -Encoding UTF8 | Select-Object -First 1
    if ($m) { return [int]$m.Matches[0].Groups[1].Value }
    return $null
}

Write-Section 'C2/B3 - canonical resolve + disk cross-check'

$statusPath = Join-Path $RepoRoot 'docs\STATUS.md'
$canonical = [ordered]@{}
$canonicalOk = $true

if (-not (Test-Path $statusPath)) {
    Write-Flag 'HIGH' (Rel $statusPath) 'docs/STATUS.md not found - cannot resolve canonical counts' 'create docs/STATUS.md CURRENT STATE table'
    $canonicalOk = $false
}
else {
    # GAP-3 (H24): canonical token -> STATUS.md CURRENT-STATE row label.
    # Row labels are READ OFF DISK, not guessed: 'Menu keys' is docs/STATUS.md:19
    # ("| Menu keys | **54** |"); 'Policies' does NOT exist yet -- W2 lands
    # "| Policies | **216** |". A token whose row is absent must FAIL-LOUD and then
    # be SKIPPED: silently resolving to $null would let the 'policy' half of GAP-3
    # sit green while measuring nothing (a detector that cannot fire is worse than
    # no detector -- it reads as coverage).
    $canonRows = [ordered]@{
        'mig'    = 'Migrations'
        'test'   = 'Tests'
        'gotcha' = 'Gotchas'
        'table'  = 'SQL tables'
        'menu'   = 'Menu keys'
        'policy' = 'Policies'
    }
    foreach ($k in $canonRows.Keys) { $canonical[$k] = Get-StatusValue $statusPath $canonRows[$k] }

    $canonShow = @()
    foreach ($k in $canonRows.Keys) {
        $v = $canonical[$k]
        if ($null -eq $v) { $canonShow += ("{0}=MISSING" -f $k) } else { $canonShow += ("{0}={1}" -f $k, $v) }
    }
    Write-Host ("  STATUS.md canonical: " + ($canonShow -join ' '))

    foreach ($k in $canonRows.Keys) {
        if ($null -eq $canonical[$k]) {
            Write-Flag 'MED' (Rel $statusPath) `
                ("canonical row missing: no '| {0} | **<n>** |' row in CURRENT STATE -> token '{1}' UNRESOLVED and SKIPPED (scanning nothing, NOT green)" -f $canonRows[$k], $k) `
                ("add the '{0}' row to the docs/STATUS.md CURRENT STATE table" -f $canonRows[$k])
        }
    }

    # ---- disk cross-check: canonical must not itself be stale ----
    # mig = migration .cs files (exclude *Designer.cs / *ModelSnapshot.cs), recursive
    #       so it survives Migrations/ vs Persistence/Migrations/ layout differences.
    $migDirs = Get-ChildItem -Path (Join-Path $RepoRoot 'src') -Recurse -Directory -Filter 'Migrations' -ErrorAction SilentlyContinue |
        Where-Object { $_.FullName -notmatch '\\(bin|obj|node_modules)\\' }
    $diskMig = 0
    foreach ($md in $migDirs) {
        $diskMig += (Get-ChildItem -Path $md.FullName -Filter *.cs -File -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -notlike '*Designer.cs' -and $_.Name -notlike '*ModelSnapshot.cs' }).Count
    }

    # gotcha = highest N from "### N." headings in docs/gotchas.md
    $gotchasPath = Join-Path $RepoRoot 'docs\gotchas.md'
    $diskGotcha = $null
    if (Test-Path $gotchasPath) {
        $nums = Select-String -Path $gotchasPath -Pattern '^### (\d+)\.' -Encoding UTF8 |
            ForEach-Object { [int]$_.Matches[0].Groups[1].Value }
        if ($nums) { $diskGotcha = ($nums | Measure-Object -Maximum).Maximum }
    }

    Write-Host ("  disk cross-check:    mig={0} gotcha={1}" -f $diskMig, $diskGotcha)

    if ($null -ne $canonical['mig'] -and $diskMig -gt 0 -and $canonical['mig'] -ne $diskMig) {
        Write-Flag 'HIGH' (Rel $statusPath) `
            ("canonical-itself-stale: STATUS Migrations=**{0}** but disk has {1} migration .cs" -f $canonical['mig'], $diskMig) `
            ("re-ground STATUS.md Migrations row to {0}" -f $diskMig)
        $canonicalOk = $false
    }
    if ($null -ne $canonical['gotcha'] -and $null -ne $diskGotcha -and $canonical['gotcha'] -ne $diskGotcha) {
        Write-Flag 'HIGH' (Rel $statusPath) `
            ("canonical-itself-stale: STATUS Gotchas=**{0}** but docs/gotchas.md max anchor is {1}" -f $canonical['gotcha'], $diskGotcha) `
            ("re-ground STATUS.md Gotchas row to {0}" -f $diskGotcha)
        $canonicalOk = $false
    }
    if ($canonicalOk) {
        Write-Host '  [OK] canonical matches disk (mig + gotcha) - safe baseline for derived scan' -ForegroundColor Green
    }
}

# ---------------------------------------------------------------------------
# C2/B3 - derived-staleness scan
# Derived docs that summarize counts; each should match canonical OR be a pointer.
# ---------------------------------------------------------------------------
Write-Section 'C2/B3 - derived-doc staleness'

# token-regex -> canonical key. Vietnamese tokens built from code points (ASCII source).
$countPatterns = @(
    @{ Rx = '(\d+)\s*migration';                          Key = 'mig';    Label = 'migration' },
    @{ Rx = '(\d+)\s*test';                               Key = 'test';   Label = 'test' },
    @{ Rx = ('(\d+)\s*(?:' + $VN_BAY + '|gotcha)');       Key = 'gotcha'; Label = 'gotcha/bay' },
    @{ Rx = ('(\d+)\s*(?:' + $VN_BANG + '|table)');       Key = 'table';  Label = 'table/bang' },
    # GAP-3 (H24): 'menu' + 'policy' were BLIND. Both resolve via $canonRows above;
    # a token whose canonical row is missing is skipped by the $null guard below
    # (already FAIL-LOUD flagged at resolve time), so 'policy' stays inert until W2.
    @{ Rx = '(\d+)\s*menu';                               Key = 'menu';   Label = 'menu' },
    @{ Rx = '(\d+)\s*polic(?:y|ies)';                     Key = 'policy'; Label = 'policy' }
)

# H18-A(b) scan-range follows the REAL rule-range (S100, Harness-18 adopt): the derived-doc
# set expands DYNAMICALLY to ALL skill SKILL.md + ALL command *.md. Rationale (real pain):
# a stale hard-count "(68)" survived ~11 sessions inside .claude/commands/session-start.md
# because commands/ sat OUTSIDE this fixed list (blind class, H1 F3 catch S99). A fixed list
# self-ages as the rule-set grows; the glob tracks the live set. Test-Excluded still applies.
$derivedDocs = @(
    'CLAUDE.md',
    'docs\CLAUDE.md',
    '.claude\skills\README.md'
) | ForEach-Object { Join-Path $RepoRoot $_ }
$derivedDocs += @(Get-ChildItem -Path (Join-Path $RepoRoot '.claude\skills') -Recurse -Filter 'SKILL.md' -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })
$derivedDocs += @(Get-ChildItem -Path (Join-Path $RepoRoot '.claude\commands') -Filter '*.md' -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })

foreach ($doc in $derivedDocs) {
    if (-not (Test-Path $doc)) { continue }
    if (Test-Excluded $doc) { continue }
    $lines = Get-Content -Path $doc -Encoding UTF8
    for ($i = 0; $i -lt $lines.Count; $i++) {
        $line = $lines[$i]
        # C2 FP-reduction (R2 refinement S75): per-item table rows + frozen-historical lines are NOT state-count claims
        if ($line -match '^\s*\|') { continue }
        if ($line -match '(?i)(baseline|\bS\d{2}\b|\(current\b)') { continue }
        foreach ($cp in $countPatterns) {
            $canon = $canonical[$cp.Key]
            if ($null -eq $canon) { continue }
            foreach ($m in [regex]::Matches($line, $cp.Rx)) {
                $pre = $line.Substring([Math]::Max(0, $m.Index - 12), [Math]::Min(12, $m.Index))
                if ($pre -match '(?i)(core|\.net|react|vite|node|mig|phase|session|version)\s*$') { continue }  # version/ordinal token, not a state-count
                $postIdx = $m.Index + $m.Length
                $post = $line.Substring($postIdx, [Math]::Min(10, $line.Length - $postIdx))
                if ($cp.Key -eq 'test' -and $post -match '^\s*project') { continue }  # "N test project" = project count, not test count
                $n = [int]$m.Groups[1].Value
                if ($n -ne $canon) {
                    # H18-A(a) mismatch-only CONFIDENCE band (S100): a stale TOTAL lags canonical
                    # by a few sessions so it lands NEAR canonical (ratio 0.5..2.0) -> MED.
                    # A module-local count ("6 test PeWorkflowDefinition" vs canonical 440) sits
                    # FAR from the total -> LOW advisory (likely a different quantity sharing the
                    # token word, NOT a stale claim). The old |diff|>=10 rule was BACKWARDS for
                    # that class (big diff = high sev = false alarm; S98 verify: 8/8 MED were FP).
                    # Correct restatement (n == canon) still never flags (mismatch-only base).
                    $ratio = if ($canon -gt 0) { [double]$n / [double]$canon } else { 0.0 }
                    $sev = if ($ratio -ge 0.5 -and $ratio -le 2.0) { 'MED' } else { 'LOW' }
                    Write-Flag $sev ("{0}:{1}" -f (Rel $doc), ($i + 1)) `
                        ("derived-stale: writes {0} {1} but canonical={2}" -f $n, $cp.Label, $canon) `
                        ("update to {0} OR replace with pointer '-> docs/STATUS.md'" -f $canon)
                }
            }
        }
    }
}
Write-Host '  (note: count-token grep is a soft net - module-local phrases like "4 bang Budget" / "71 test (Phase 8)" can false-positive; H18-A ratio-band demotes far-from-canonical counts to LOW = review-not-fail, near-canonical stale-totals stay MED)' -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C1 - broken-pointer: gotcha #N refs
# ---------------------------------------------------------------------------
Write-Section 'C1 - broken gotcha-ref'

$maxGotcha = $canonical['gotcha']
$gotchasPath = Join-Path $RepoRoot 'docs\gotchas.md'
$gotchaAnchors = @{}
if (Test-Path $gotchasPath) {
    Select-String -Path $gotchasPath -Pattern '^### (\d+)\.' -Encoding UTF8 |
        ForEach-Object { $gotchaAnchors[[int]$_.Matches[0].Groups[1].Value] = $true }
}

# --- L7-BOLT (C-2 @S185, medicine of broadcast 2026-08-05 "Lop 7 - neo vao hinh dang
# chu do nguoi go", Ro C) ---------------------------------------------------------
# '### N.' is a HUMAN-TYPED heading glyph. Let the writer file the two newest gotchas as
# "### 87 -" or "#### 87." and they drop OUT of $gotchaAnchors in silence -- after which
# this detector keeps emitting CONFIDENT per-ref lines ("'gotcha #87' has no anchor")
# that are pure artefacts of a dead parse, not findings. That is the exact failure the
# broadcast reports: the machine slid past the newest lines, re-anchored on an older
# block and printed 13 overdue items to the session when the true number was 0.
#
# The medicine has TWO layers and layer 2 is the load-bearing one:
#   (1) do not anchor on the glyph  -> not fixable here without owning gotchas.md's
#       heading convention (= owner ground), so it is NOT attempted; declared instead.
#   (2) add a live/dead BOLT read from an INDEPENDENT source: the newest anchor parsed
#       must not sit more than ONE step behind canonical. Canonical here is the
#       docs/STATUS.md Gotchas row -- a different FILE reached by a different parse
#       (Get-StatusValue table-row regex), so a heading-shape change cannot move both.
#       Off by more than one step => print "chua do duoc" and emit NO anchor-derived
#       number. Without the bolt a "0" and a "13" are equally meaningless.
# Tolerance is ONE step by construction (a gotcha counted in STATUS whose section is
# written a beat later is normal drift, not a dead parse) - that is the broadcast's own
# wording ("khong cu hon moc moi nhat qua mot bac"), not a threshold invented here.
# SCOPE of the suppression: only the ANCHOR-EXISTENCE verdict is withheld. The
# range check (cites #N > canonical max) does NOT read $gotchaAnchors at all, so it
# keeps firing -- withholding it would hide a real witness behind a parse problem.
$anchorMaxGotcha = 0
foreach ($ak in $gotchaAnchors.Keys) { if ($ak -gt $anchorMaxGotcha) { $anchorMaxGotcha = $ak } }
$gotchaAnchorLive = $true
$gotchaAnchorGap = 0
if ($null -ne $maxGotcha -and $gotchaAnchors.Count -gt 0) {
    $gotchaAnchorGap = $maxGotcha - $anchorMaxGotcha
    if ($gotchaAnchorGap -gt 1) { $gotchaAnchorLive = $false }
}
$gotchaAnchorSuppressed = 0

if ($null -eq $maxGotcha -or $gotchaAnchors.Count -eq 0) {
    Write-Host '  [skip] no canonical max-gotcha or no anchors parsed - cannot validate gotcha refs' -ForegroundColor DarkGray
}
else {
    if (-not $gotchaAnchorLive) {
        Write-Host ("  CHUA DO DUOC (L7-bolt): newest '### N.' anchor parsed = {0} but canonical (docs/STATUS.md Gotchas) = {1} -> the heading anchor is {2} steps behind, i.e. the parse is DEAD, not the docs. Anchor-existence verdict WITHHELD - no number is emitted from a dead anchor." -f `
                $anchorMaxGotcha, $maxGotcha, $gotchaAnchorGap) -ForegroundColor Yellow
        Write-InformFlag ("docs/gotchas.md") `
            ("L7-bolt: gotcha anchor parse is DEAD (newest '### N.' anchor = {0}, canonical = {1}, gap {2} > 1) - anchor-existence checking measured NOTHING this run (chua do duoc, NOT clean)" -f $anchorMaxGotcha, $maxGotcha, $gotchaAnchorGap) `
            "re-file the newest gotcha section(s) as '### <N>. <title>' so the anchor parse reaches canonical, OR re-ground the docs/STATUS.md Gotchas row if IT is the stale side"
    }
    else {
        Write-Host ("  [ok] L7-bolt: newest anchor {0} vs canonical {1} (gap {2} <= 1) - anchor parse is LIVE, numbers below are measured" -f `
                $anchorMaxGotcha, $maxGotcha, $gotchaAnchorGap) -ForegroundColor DarkGray
    }
    # Match "gotcha #N", "gotcha N", and bare "#N" tokens.
    # S151 fix (FP #111, H1 F-6 + ring1 confirm): the word-branch used to swallow
    # "gotchas 111.616B" (a BYTE-SIZE, thousands-separator) and flag a phantom #111.
    # Guard: the digits must NOT be followed by a decimal/thousands separator + digit
    # (111.616 / 111,616) nor glued to a letter (111B) - real refs ("#57", "gotcha 81-EXT",
    # "gotcha 57,") are unaffected. Word-boundary before 'gotcha' kills mid-word hits.
    # (?!\d) pins the capture to the FULL digit-run first - without it the engine
    # backtracks THROUGH the guard ("gotchas 111.616B" -> retries as "11" and matches
    # a phantom #11; caught by fault-inject S151). Then the separator guard applies.
    $refRx = '(?:\bgotcha[s]?\s*#?(\d+)(?!\d)(?![.,]\d|[A-Za-z]))|(?<![A-Za-z0-9])#(\d+)'
    foreach ($f in $GovMd) {
        $lines = Get-Content -Path $f.FullName -Encoding UTF8
        for ($i = 0; $i -lt $lines.Count; $i++) {
            foreach ($m in [regex]::Matches($lines[$i], $refRx)) {
                $num = if ($m.Groups[1].Success) { [int]$m.Groups[1].Value } else { [int]$m.Groups[2].Value }
                $isGotchaWord = $m.Groups[1].Success
                # bare "#N": only treat as gotcha-ref candidate when N is in gotcha numeric range
                # to avoid PR/issue/run numbers. gotcha-word form always validated.
                if (-not $isGotchaWord) {
                    if ($num -le 0 -or $num -gt ($maxGotcha + 50)) { continue }
                    # bare #N with N <= maxGotcha and anchor exists -> fine, skip silently
                    if ($num -le $maxGotcha -and $gotchaAnchors.ContainsKey($num)) { continue }
                    # bare #N > maxGotcha is ambiguous (could be Run #312) -> skip to avoid noise
                    if ($num -gt $maxGotcha) { continue }
                }
                if ($num -gt $maxGotcha) {
                    Write-Flag 'MED' ("{0}:{1}" -f (Rel $f.FullName), ($i + 1)) `
                        ("broken-gotcha-ref: cites #{0} but max gotcha is {1}" -f $num, $maxGotcha) `
                        'fix the number or add the gotcha to docs/gotchas.md'
                }
                elseif ($isGotchaWord -and -not $gotchaAnchors.ContainsKey($num)) {
                    # L7-bolt (C-2): a dead anchor parse makes EVERY ref look anchorless.
                    # Count what is withheld and print the count, so the drop in emitted
                    # lines is told by its COMPONENTS and can never read as "got cleaner".
                    if (-not $gotchaAnchorLive) { $gotchaAnchorSuppressed++; continue }
                    Write-Flag 'LOW' ("{0}:{1}" -f (Rel $f.FullName), ($i + 1)) `
                        ("broken-gotcha-ref: 'gotcha #{0}' has no '### {0}.' anchor in gotchas.md" -f $num) `
                        'fix ref or create the missing gotcha anchor'
                }
            }
        }
    }
    if (-not $gotchaAnchorLive) {
        Write-Host ("  L7-bolt withheld {0} anchor-existence line(s) this run (they would all be artefacts of the dead parse, not findings)" -f $gotchaAnchorSuppressed) -ForegroundColor Yellow
    }
}

# ---------------------------------------------------------------------------
# C1 - broken-pointer: dangling [[wikilink]]  (user-memory + agent-memory)
# ---------------------------------------------------------------------------
Write-Section 'C1 - dangling wikilink'

# user-memory dir (outside repo). DERIVED from $RepoRoot, not hardcoded.
#
# FIXED S122 W5 -- the old line was:
#     $userMemDir = 'C:\Users\pqhuy\.claude\projects\D--Dropbox-...-SOLUTION-ERP\memory'
# ...directly under a comment that claimed "Derive from this machine's project slug".
# The comment described the INTENT; the code hardcoded an absolute path AND a username.
# Two concrete harms, not style:
#   1) -RepoRoot <temp-tree> still read the REAL user-memory => fault-injection could not
#      isolate. The C1-wikilink teeth-test (prove the detector FLAGS on injected breakage)
#      was therefore unrunnable -- the one tool built to prove teeth had no teeth itself.
#   2) Hardcoded 'pqhuy' => the script silently degrades to the else-branch on any other
#      machine/account, and a silent degrade reads exactly like "clean".
#
# The Claude Code project slug IS $RepoRoot with [:\/_] collapsed to '-'. Verified S122:
#   D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP -> D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP
#   (exact match against the previously hardcoded literal; Test-Path = True)
# and a temp RepoRoot yields a different slug whose path does NOT exist => the else-branch
# fires => fault-injection isolates. That is the whole point of deriving it.
$projectSlug = $RepoRoot -replace '[:\\/_]', '-'
$userMemDir  = Join-Path $env:USERPROFILE ".claude\projects\$projectSlug\memory"
$agentMemDir = Join-Path $RepoRoot '.claude\agent-memory'

$memScopes = @()
if (Test-Path $userMemDir)  { $memScopes += [pscustomobject]@{ Name = 'user-memory';  Dir = $userMemDir;  Recurse = $false } }
else { Write-Host "  [note] user-memory path not reachable ($userMemDir) - scanning in-repo agent-memory only" -ForegroundColor DarkGray }
if (Test-Path $agentMemDir) { $memScopes += [pscustomobject]@{ Name = 'agent-memory'; Dir = $agentMemDir; Recurse = $true } }

foreach ($scope in $memScopes) {
    $gp = if ($scope.Recurse) {
        Get-ChildItem -Path $scope.Dir -Recurse -Filter *.md -File -ErrorAction SilentlyContinue
    } else {
        Get-ChildItem -Path $scope.Dir -Filter *.md -File -ErrorAction SilentlyContinue
    }
    # Build the set of existing target basenames in this scope.
    $targets = @{}
    foreach ($g in $gp) { $targets[$g.BaseName] = $true; $targets[($g.BaseName -replace '[-_]', '')] = $true }  # C1 refinement (R2 S75): also index separator-normalized form (hyphen<->underscore fork)

    foreach ($g in $gp) {
        $lines = Get-Content -Path $g.FullName -Encoding UTF8
        for ($i = 0; $i -lt $lines.Count; $i++) {
            foreach ($m in [regex]::Matches($lines[$i], '\[\[([a-z0-9_-]+)\]\]')) {
                $tgt = $m.Groups[1].Value
                if (-not ($targets.ContainsKey($tgt) -or $targets.ContainsKey(($tgt -replace '[-_]', '')))) {
                    Write-Flag 'LOW' ("{0}/{1}:{2}" -f $scope.Name, $g.Name, ($i + 1)) `
                        ("dangling-wikilink: [[{0}]] -> {0}.md not found in {1}" -f $tgt, $scope.Name) `
                        'fix the link target or create the file (note: hyphen vs underscore basename fork is common)'
                }
            }
        }
    }
}

# ---------------------------------------------------------------------------
# C3 - vocab-fork
# ---------------------------------------------------------------------------
Write-Section 'C3 - vocab-fork'

# Seed alias-sets (hard-coded from audit; extend over time). Vietnamese variants
# built from code points so the .ps1 stays ASCII-only (gotcha #30) yet matches
# correctly-decoded UTF-8 content.
$aliasSets = @(
    @('wave-folder', 'run-trace'),
    @($VN_DUTRU_PRO, $VN_NGANSACH_PRO),
    @('two-tier', 'all-inherit', 'worker-tier-pin')
)
# NOTE (S126, 2026-07-16): 'worker-tier-pin' added as 3rd variant - owner-decision S124 made it
# the canonical term (vocab-alias-map.md section 3); the B1 sweep found the fork all-inherit-vs-
# worker-tier-pin LIVE in 5 governance files (patched S126) and this seed could not see it.
# NOTE (Harness-16, S93): "memory-fidelity" is INTENTIONALLY NOT seeded here. It is
# NOT a vocab-fork (one concept / two names) -- it is two DISTINCT concepts sharing a
# word: H6.7 "memoryDelta-routing-fidelity" (delta lands in the right agent-memory)
# vs Harness-16 "memory-fidelity-EVAL / MFE" (coverage/retention). The alias-map is
# RECORDED in docs/governance/harness-11-engine.md PHAN H. Seeding it would FALSE-flag
# two legitimately-different terms as a fork-to-merge -- do not add it.
# NOTE (Harness-17, S95): "coverage" is likewise INTENTIONALLY NOT seeded. It is THREE
# distinct concepts sharing a word, not a fork: (1) H2 harvest-curator "Coverage"
# (0-silent-miss of spawned subs/runs) ; (2) H16 MFE SUB "coverage" (role-floor still
# carried in the diary) ; (3) H17 A1 "HCV / harvest-coverage" (per-run said-vs-kept).
# The alias-map is RECORDED in harness-11-engine.md PHAN I. Seeding would FALSE-flag three
# legitimately-different terms as a fork-to-merge -- do not add it. Same for "audit"
# (monthly-drift-audit vs H17 spec-audit) -- disambiguated by name in-doc, not seeded.

for ($s = 0; $s -lt $aliasSets.Count; $s++) {
    $variants = $aliasSets[$s]
    $perVariantFiles = @{}
    foreach ($v in $variants) { $perVariantFiles[$v] = New-Object System.Collections.Generic.List[string] }

    foreach ($f in $GovMd) {
        $content = Get-Content -Path $f.FullName -Raw -Encoding UTF8
        if ($null -eq $content) { continue }
        foreach ($v in $variants) {
            if ($content -match [regex]::Escape($v)) {
                $perVariantFiles[$v].Add((Rel $f.FullName)) | Out-Null
            }
        }
    }

    $liveVariants = @($variants | Where-Object { $perVariantFiles[$_].Count -gt 0 })
    if ($liveVariants.Count -ge 2) {
        $detail = ($liveVariants | ForEach-Object { "{0}={1}f" -f $_, $perVariantFiles[$_].Count }) -join ' vs '
        $sample = ($liveVariants | ForEach-Object {
                $first = $perVariantFiles[$_] | Select-Object -First 2
                "'$_' in [$($first -join ', ')]"
            }) -join ' | '
        Write-Flag 'MED' 'multiple files' `
            ("vocab-fork: $detail live side-by-side -- $sample") `
            'merge to ONE canonical term, or record an alias-map in docs/governance'
    }
}

# ---------------------------------------------------------------------------
# C5 - WAL guardrail (H22 S111, reviewer-m1): .claude/WAL.md hard cap 40 lines
# (N.1 overwrite-not-append). DETECT-only nhu moi detector khac.
# ---------------------------------------------------------------------------
Write-Section 'C5 - WAL guardrail (H22)'
$walPath = Join-Path $RepoRoot '.claude\WAL.md'
if (Test-Path $walPath) {
    $walLineCount = @(Get-Content $walPath).Count
    if ($walLineCount -gt 40) {
        Write-Flag 'MED' '.claude/WAL.md' `
            ("wal-overflow: {0} lines > 40-line hard cap (H22 N.1 overwrite-not-append)" -f $walLineCount) `
            'trim WAL to <= 40 lines: move long notes to run-folder/work-state; keep chain+next+verify only'
    } else {
        Write-Host ("  [OK] WAL.md = {0} lines (<= 40 hard cap)" -f $walLineCount)
    }
} else {
    Write-Host '  (no .claude/WAL.md - no active chain, skip)'
}

# ---------------------------------------------------------------------------
# H24-1 - title-freshness (do-tuoi-tieu-de)
#   moc-phai (right edge) = newest governance milestone = MAX valid anchor date in corpus
#   moc-trai (left edge)  = the date carried by the doc's OWN title/status anchor
#   FLAG when trai < phai.  No anchor => SKIP (a doc that makes no freshness claim
#   cannot make a STALE one).
#
# WHY ANCHOR-SCOPED, NOT "any date on the line" -- this is the load-bearing bit.
# Measured on disk: docs/STATUS.md:6 is a 69,870-char MEGA-LINE carrying 41
# date-shaped tokens. Most are NOT dates: owner-mark ids of the form
# RC-pqhuy1987-12-07-2026-11-43-45 get shredded by a bare \d{4}-\d{2}-\d{2} into
# GARBAGE pseudo-dates (1987-12-07 from the ...1987-12-07... slice, 2026-11-43,
# 2026-20-42 -- month 20, day 42). The line also holds a REAL future date
# (2026-08-01 = next monthly audit due). So:
#   - date-max over the line  -> 2026-20-42 (garbage) or 2026-08-01 (future)
#   - either makes moc-phai unreachable => trai < phai never true => 0 flags forever
#     => the detector silently strangles its OWN positive-control and reads green.
# Hence: capture ONLY the date bound to the anchor + strict calendar validation
# (TryParseExact rejects month-20/day-42 outright).
#
# WHY anchor_patterns IS A LIST (>=2 forms) -- also load-bearing:
# .claude/skills/*/SKILL.md carry NO "Last updated" anchor (grep "Last updated"
# in .claude/skills/ = 0 hit, verified). They use the OTHER form:
# "**Status (post Session N <em-dash> YYYY-MM-DD):**". A single "Last updated"
# regex + the "no anchor => skip" rule would drop permission-matrix/SKILL.md --
# the exact positive-control this detector exists to fire on.
# ---------------------------------------------------------------------------
Write-Section 'H24-1 - title-freshness'

$AnchorPatterns = @(
    '\*\*Last updated:\*\*\s*(\d{4}-\d{2}-\d{2})',
    ('\*\*Status \(post Session \d+ ' + $EM_DASH + ' (\d{4}-\d{2}-\d{2})\)\:\*\*')
)

# docs/_archive/ is FROZEN-BY-DESIGN (verbatim pre-S40 snapshots kept as historical
# record). Their old anchor date is the POINT of the file, and the only "resolve"
# action -- refresh the date -- would destroy the record. A flag whose resolve is
# forbidden is permanent noise, so archives are out of THIS detector's scope.
# Scoped LOCALLY (not added to the global Test-Excluded) so C1/C3 keep scanning them
# exactly as before -- widening the global exclude would silently move other
# detectors' baselines, which is not this lane's call.
$TitleFreshSkip = @('\docs\_archive\')

# BACKTICK-GUARD (S123) -- the self-reference trap, measured not guessed.
# A doc that DEFINES the stale-anchor anti-pattern must QUOTE a stale anchor to
# explain it. That quote is byte-identical to a live anchor -- being identical is
# the POINT of a good example -- so a pattern-matcher flags the teacher.
# Measured on disk @S123: 4 H24-1 flags, 2 are this exact case:
#   .claude/agents/lead-stale-auditor.md:45          quotes the anchor as the EXAMPLE
#                                                   for its own class `view-stale-header`
#   docs/governance/adap-reports/...harness-24...:78 quotes it while EXPLAINING that
#                                                   very false-positive  <- 3rd generation
# The defect PROPAGATES BY CITATION: every doc written ABOUT the false-positive
# becomes one. Root instance = permission-matrix/SKILL.md:16 (a REAL defect, fixed W4).
#
# WHY NOT the H24-2 mechanism (charset-discriminator, :670-672): that guard works
# because the format-spec literal "[carry:<slug>]" carries a '<' that a real key can
# NEVER contain. Here there is no such character -- example and claim are byte-equal.
# A DIFFERENT discriminator is required: not the anchor's SHAPE, but its ENCLOSURE.
#
# THE RULE: an anchor sitting inside an inline-code span (`...`) is being MENTIONED,
# not USED (use/mention distinction). Count backticks left of the match; ODD => we are
# inside an open span => quoted example => keep looking. Measured 4/4 ON DISK TODAY:
#   BARE   -> docs/rag-setup-plan.md:4 . form-engine/SKILL.md:15   = TRUE POSITIVE, still fires
#   QUOTED -> lead-stale-auditor.md:45 . adap-report...:78          = FALSE POSITIVE, now skipped
#
# 4/4, NOT the "5/5" an earlier draft of this comment claimed (fixed S123 after review).
# The would-be 5th case, permission-matrix/SKILL.md:16, IS NOT ON DISK ANY MORE and does
# NOT fire -- W4/S122 "fixed" it by rewriting the anchor into "**Status (cap-nhat ...):**",
# a form that matches NEITHER $AnchorPatterns entry. So it does not even parse, let alone
# flag. Verified: both patterns return False against that line.
#   -> ADJACENT HAZARD, worth naming: that file left the MEASUREMENT SET instead of getting
#      fresh. H24-1 thereby lost the exact positive-control it was built around (see the
#      anchor_patterns note below: pattern #2 exists BECAUSE of permission-matrix). Flag
#      count fell, which reads like success -- Goodhart in its quietest form. Do NOT
#      "resolve" a title-stale flag by reshaping the anchor; refresh the date or declare
#      the doc frozen-historical.
#   -> pattern #2 now matches ONLY form-engine/SKILL.md:15. If that one is ever reshaped
#      too, pattern #2 becomes dead code that exists purely to catch quoted examples.
#
# WHY NO head-N-lines scope on top: position is a PROXY, and the backtick rule already
# separates the measured set exactly. Stacking a proxy buys zero measured precision while
# adding a NEW blind spot -- a doc that legitimately anchors below line N goes silently
# unwatched. Mark RC-pqhuy1987-20-06-2026-10-29-11 (proxy-instead-of-signal = false
# economy) applies: use the real signal, not its shadow.
#
# HONEST LIMIT (do not read this as closed): the guard reads INLINE spans only. An
# anchor quoted inside a fenced ``` block has ZERO backticks on its own line => reads
# BARE => still a false positive. Measured 0 such cases today (383 files scanned, 6 anchor
# hits, none inside a fence), so it is left unhandled rather than fixed blind. Same for
# escaped \` and ``double-tick`` spans.

# Test-Quoted -- SHARED by H24-1 (Get-AnchorDate) and H24-3 (session-label).
# Deliberately a FUNCTION, not two inline copies: the citation trap is a CLASS, and the
# S123 review caught H24-3 shipping WITHOUT this guard in the very diff that proved the
# class exists. One greppable definition is how the next detector inherits the fix instead
# of re-earning it. If you add a detector that pattern-matches prose, call this.
function Test-Quoted {
    param([string]$Line, [int]$Index)
    return ((([regex]::Matches($Line.Substring(0, $Index), '`')).Count % 2) -eq 1)
}

function Get-AnchorDate {
    param([string]$Path)
    $ls = Get-Content -Path $Path -Encoding UTF8 -ErrorAction SilentlyContinue
    if ($null -eq $ls) { return $null }
    for ($i = 0; $i -lt $ls.Count; $i++) {
        foreach ($rx in $AnchorPatterns) {
            # Matches (all), not Match (first): one line may carry a quoted example
            # BEFORE a live anchor. Taking only the first match would let the example
            # shadow the real claim on that line.
            foreach ($m in [regex]::Matches($ls[$i], $rx)) {
                if (Test-Quoted $ls[$i] $m.Index) { continue }  # inside `...` => mentioned, not used
                $raw = $m.Groups[1].Value
                $dt = [datetime]::MinValue
                $ok = [datetime]::TryParseExact($raw, 'yyyy-MM-dd',
                    [Globalization.CultureInfo]::InvariantCulture,
                    [Globalization.DateTimeStyles]::None, [ref]$dt)
                if ($ok) {
                    return [pscustomobject]@{ Date = $dt; Raw = $raw; Line = ($i + 1) }
                }
                # anchor present but date not a real calendar date -> keep looking
            }
        }
    }
    return $null
}

$anchored = @()
foreach ($f in $GovMd) {
    $p = ($f.FullName -replace '/', '\')
    $skip = $false
    foreach ($frag in $TitleFreshSkip) { if ($p -ilike "*$frag*") { $skip = $true } }
    if ($skip) { continue }
    $a = Get-AnchorDate $f.FullName
    if ($null -ne $a) {
        $anchored += [pscustomobject]@{
            Rel = (Rel $f.FullName); Date = $a.Date; Raw = $a.Raw; Line = $a.Line
        }
    }
}

if ($anchored.Count -eq 0) {
    Write-Host '  [skip] no doc carries a known title/status anchor - nothing to age-compare' -ForegroundColor DarkGray
}
else {
    $newest = ($anchored | Sort-Object Date -Descending | Select-Object -First 1)
    Write-Host ("  anchors parsed: {0} doc(s) ; moc-phai (newest governance milestone) = {1} from {2}:{3}" -f `
            $anchored.Count, $newest.Raw, $newest.Rel, $newest.Line)
    foreach ($a in ($anchored | Sort-Object Date -Descending)) {
        Write-Host ("    anchor {0}  {1}:{2}" -f $a.Raw, $a.Rel, $a.Line) -ForegroundColor DarkGray
    }
    # UNIT = SESSIONS not days (YC-020(2) @S185, owner verbatim: 'dong y 14 ngay. (nhung cung
    # theo phien luon vi co ngay lam ngay ko)'). A day with 3 ticks ages 3; a dead day ages 0.
    # sessions-behind = counter-history ticks strictly AFTER anchor date and <= newest date.
    # Threshold = owner key h24_title_freshness_sessions (memory-budget.json).
    # Key missing => CHUA DO DUOC (fail-loud, mirror H24-2 - no hardcoded default).
    $tfKey = $null
    try {
        $budJ = Get-Content -Path (Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json') -Raw -Encoding UTF8 | ConvertFrom-Json
        $tfKey = $budJ.h24_title_freshness_sessions
    } catch {}
    $tickDates = @()
    try {
        $cntJ = Get-Content -Path (Join-Path $RepoRoot '.claude\governance\.session-counter.json') -Raw -Encoding UTF8 | ConvertFrom-Json
        foreach ($h in $cntJ.history) {
            $dt = [datetime]::MinValue
            if ([datetime]::TryParseExact([string]$h.at, 'yyyy-MM-dd', $null, [System.Globalization.DateTimeStyles]::None, [ref]$dt)) { $tickDates += $dt }
        }
    } catch {}
    if ($null -eq $tfKey) {
        Write-Host '  CHUA DO DUOC (owner-key): h24_title_freshness_sessions MISSING in memory-budget.json - session-age verdicts WITHHELD (no default; mirror H24-2 fail-loud)' -ForegroundColor Yellow
    }
    elseif ($tickDates.Count -eq 0) {
        Write-Host '  CHUA DO DUOC (tick-source): .session-counter.json history unreadable/empty - cannot count sessions-behind, verdicts WITHHELD' -ForegroundColor Yellow
    }
    else {
        foreach ($a in $anchored) {
            if ($a.Date -lt $newest.Date) {
                $ageDays = [int]($newest.Date - $a.Date).TotalDays
                $ageSess = @($tickDates | Where-Object { $_ -gt $a.Date -and $_ -le $newest.Date }).Count
                if ($ageSess -ge [int]$tfKey) {
                    Write-Flag 'LOW' ("{0}:{1}" -f $a.Rel, $a.Line) `
                        ("title-stale: anchor says {0} but newest governance milestone is {1} ({2} SESSIONS behind [{3}d] >= owner threshold {4})" -f $a.Raw, $newest.Raw, $ageSess, $ageDays, $tfKey) `
                        'refresh the title/status anchor date, or state explicitly that the doc is frozen-historical'
                }
                else {
                    Write-Host ("    [ok] {0}:{1} anchor {2} = {3} session(s) behind [{4}d] < threshold {5}" -f $a.Rel, $a.Line, $a.Raw, $ageSess, $ageDays, $tfKey) -ForegroundColor DarkGray
                }
            }
        }
    }
}

# ---------------------------------------------------------------------------
# H24-3 - session-label lag (do-lech-nhan-phien)  [NEW S123]
#
# WHY THIS EXISTS -- H24-1 has a PROVEN false-negative, and this is it.
# docs/STATUS.md:6 read "**Last updated:** 2026-07-15 (S119 ...)" while :35 read
# "## Recently Done (S122 ...)". Measured: S119 x3 hits in the mega-line, S120/S121/
# S122 = 0 hits => the header label had fallen 3 sessions behind the content.
# H24-1 CANNOT see this: it compares DATES, and the date 2026-07-15 was the NEWEST
# in the corpus => 0 flags. The doc was simultaneously the freshest thing on disk
# and wrong about itself.
#
# NOT a one-off: the same :6-lag was caught by hand at S116, S117, S118 and again at
# S123 -- 4 recurrences. Three of those were fixed by bumping the number, which is
# why it came back a 4th time. Per the owner's own S122 finding: what stops a repeat
# is a LAW THAT CAN SEE, not a memory that must remember. So the axis changes from
# DATE (H24-1) to SESSION LABEL (here).
#
# THE RULE: within ONE file, the "(S<N>)" on the **Last updated** line must not be
# older than the newest "(S<M>)" carried by a heading. Both numbers live in the same
# file, so there is no cross-file inference and no ratio-band -- a mismatch is wrong
# BY CONSTRUCTION. Hence MED, not the LOW that H24-1's noisier date-axis earns.
#   -> HONEST CORRECTION (S123 review): an earlier draft justified MED with "0 proxy".
#      That was WRONG. $labelN is the FIRST regex hit, which is a PROXY for "this file's
#      OWN label" -- and it breaks precisely when the file QUOTES someone else's label.
#      MED survives because Test-Quoted removes that failure mode, NOT because no proxy
#      was ever there. Naming the proxy is the point; pretending it is absent is how the
#      next reader stops looking for it.
#
# TWO BUGS THIS DETECTOR SHIPPED AND THE S123 REVIEW CAUGHT -- both worth remembering:
#   (a) NO Test-Quoted guard. The same diff that added this detector ALSO added 30 lines
#       proving "every doc written ABOUT the false-positive becomes one" -- and then built
#       a new prose-matcher without the guard. Generation-2 of the very trap. The canonical
#       trigger would have been the S123 adap-report DESCRIBING this detector.
#       => the lesson is mechanical, not moral: patch a trap CLASS, then grep every
#          same-class matcher IN THAT DIFF. A lesson sitting in context does not self-fire.
#   (b) GREEDY '.*' took the LAST "(S<N>" on a line, not the MAX. Isolated repro:
#       "## Tong hop (S122 ...) va lich su (S110 ...)" -> regex yielded S110, max is S122
#       => could print "[ok] label S115 >= newest heading S110", a false-negative stated
#          as a fact about the file. Fixed by scanning ALL matches per line and taking max.
#       The 6/6 fault-inject missed it because every case put ONE "(S<N>" per heading:
#       N/N PASS proves the axes TOUCHED, never the axis never tried. Ask instead:
#       "which axis has no case at all?" (corpus has 0 such headings today => was LATENT).
#
# Measured @S123 on the live corpus (both directions, no tuning):
#   docs/STATUS.md   label S119 vs max-heading S122 -> 119 < 122 => FLAG   (the real defect)
#   docs/HANDOFF.md  label S122 vs max-heading  S84 -> 122 > 84  => silent
# HANDOFF needs NO special case: its only S-heading is an archive pointer
# ("Session detail cu (S84 -> tro ve truoc)"), and a pointer to old sessions is
# exactly what a CURRENT label should outrank. The rule reads that correctly on its own.
#
# HONEST LIMIT: this catches a label that lags its OWN headings. A file whose label
# AND headings are both stale together stays silent -- consistency is not freshness.
# That gap is real and left open rather than papered over with a second proxy.
# ---------------------------------------------------------------------------
Write-Section 'H24-3 - session-label lag'

$SessLabelRx = '\*\*Last updated:\*\*[^(]*\(S(\d+)'
# Heading side is scanned in TWO steps, NOT one regex: '^#{2,}\s' decides "is this a
# heading?", then EVERY '\(S(\d+)' on that line is collected and max-ed. The old
# single-regex '^#{2,}\s+.*\(S(\d+)' looked equivalent and was not -- greedy '.*' made it
# "the LAST (S<N> on the line", which is a different question from "the newest".
$SessHeadingLineRx = '^#{2,}\s'
$SessNumRx = '\(S(\d+)'

$sessChecked = 0
foreach ($f in $GovMd) {
    $p = ($f.FullName -replace '/', '\')
    $skip = $false
    # Same frozen-by-design carve-out as H24-1: an archive's old label IS the record.
    foreach ($frag in $TitleFreshSkip) { if ($p -ilike "*$frag*") { $skip = $true } }
    if ($skip) { continue }

    $ls = Get-Content -Path $f.FullName -Encoding UTF8 -ErrorAction SilentlyContinue
    if ($null -eq $ls) { continue }

    $labelN = $null; $labelLine = 0
    $maxHeadN = $null; $maxHeadLine = 0
    for ($i = 0; $i -lt $ls.Count; $i++) {
        # LABEL: first UNQUOTED hit wins. Matches (all) + Test-Quoted, so a line that
        # quotes an example label before carrying a real one still yields the real one.
        if ($null -eq $labelN) {
            foreach ($lm in [regex]::Matches($ls[$i], $SessLabelRx)) {
                if (Test-Quoted $ls[$i] $lm.Index) { continue }
                $labelN = [int]$lm.Groups[1].Value; $labelLine = $i + 1
                break
            }
        }
        # HEADING: MAX over ALL unquoted hits ON the line (not the first, not the last).
        # $SessHeadingRx anchors '^#{2,}' so it only matches at line start; iterating
        # Matches() would re-scan the same line, so strip the anchor and scan the tail.
        if ($ls[$i] -match $SessHeadingLineRx) {
            foreach ($hm in [regex]::Matches($ls[$i], $SessNumRx)) {
                if (Test-Quoted $ls[$i] $hm.Index) { continue }
                $hn = [int]$hm.Groups[1].Value
                if (($null -eq $maxHeadN) -or ($hn -gt $maxHeadN)) { $maxHeadN = $hn; $maxHeadLine = $i + 1 }
            }
        }
    }

    # No label, or no S-heading to compare against => this doc makes no session claim.
    if (($null -eq $labelN) -or ($null -eq $maxHeadN)) { continue }
    $sessChecked++

    if ($labelN -lt $maxHeadN) {
        Write-Flag 'MED' ("{0}:{1}" -f (Rel $f.FullName), $labelLine) `
        ("session-label lag: header label says S{0} but the file's own newest heading is S{1} (line {2}, {3} session(s) ahead)" -f `
                $labelN, $maxHeadN, $maxHeadLine, ($maxHeadN - $labelN)) `
            'bump the header label to match the newest section, OR drop the session label from the header so it cannot go stale (B1 collapse)'
    }
    else {
        Write-Host ("  [ok] {0}: label S{1} >= newest heading S{2}" -f (Rel $f.FullName), $labelN, $maxHeadN) -ForegroundColor DarkGray
    }
}
if ($sessChecked -eq 0) {
    Write-Host '  [skip] no doc carries BOTH a session label and an S-heading - nothing to compare' -ForegroundColor DarkGray
}

# ---------------------------------------------------------------------------
# H24-2 - carry-age (INFORM-only)
# A [carry:<slug>] key that survives >= M CONSECUTIVE most-recent carry-lines is
# "aged" -- it has outlived a full review cadence without being closed.
#
# "carry-line" (dong-carry) = a LOGIC segment, NOT a physical line. docs/HANDOFF.md
# is 12 physical lines but line 5 alone is a ~46.5K-char mega-line holding 45
# "NEXT anh" / "NEXT em" blocks (newest-first). Get-Content -TotalCount would see
# ONE line and measure nothing, so we read -Raw and split on the NEXT markers.
#
# Streak counts only over lines that HAVE carry: a session that emitted no carry
# does NOT break a chain. A key resets ONLY by being absent from a line that HAS
# carry. Only keys on the newest carry-line can hold a live streak (a key gone from
# the newest one is closed, not aged).
#
# M comes from config, NEVER hardcoded: a hardcoded cadence is exactly the
# single-source violation H24 forbids. Missing config/key => FAIL-LOUD + measure
# nothing. Scope note: this "no hardcoded cadence" rule is about the CADENCE number
# only -- pre-existing constants elsewhere in this script are other detectors'
# owner-signed numbers and are out of scope.
# ---------------------------------------------------------------------------
Write-Section 'H24-2 - carry-age (INFORM-only)'

# Canonical config path per owner-decision Q2. Probe agent-memory/ FIRST (that is
# where the live memory-budget.json actually is on disk); the bare .claude/ path is
# a fallback in case W2 lands the key at the shorter path some docs abbreviate to.
$cfgCandidates = @(
    (Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json'),
    (Join-Path $RepoRoot '.claude\memory-budget.json')
)
$cfgPath = $null
foreach ($c in $cfgCandidates) { if ($null -eq $cfgPath) { if (Test-Path $c) { $cfgPath = $c } } }

# NOTE: named $CadenceM, NOT $M. PowerShell variable names are CASE-INSENSITIVE, so a
# bare $M is the SAME variable as the $m used by the regex-match loops below -- the
# match object silently clobbered the cadence, making ($null -eq $M) false and turning
# the fail-loud path into "Could not compare 1 to [carry:bvaau]". Keep the long name.
$CadenceM = $null
if ($null -eq $cfgPath) {
    Write-Flag 'MED' '.claude/agent-memory/memory-budget.json' `
        'carry-age config NOT FOUND at any candidate path - cadence unresolved, carry-age measuring NOTHING' `
        'create memory-budget.json carrying h24_cadence { light_every, deep_every, jump_on_class_repeat }'
}
else {
    Write-Host ("  config resolved: {0}" -f (Rel $cfgPath))
    $cfg = $null
    try { $cfg = (Get-Content -Path $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json) }
    catch { $cfg = $null }
    if ($null -eq $cfg) {
        Write-Flag 'MED' (Rel $cfgPath) `
            'carry-age config unparseable as JSON - cadence unresolved, carry-age measuring NOTHING' `
            'fix the JSON syntax'
    }
    elseif ($null -eq $cfg.h24_cadence) {
        Write-Flag 'MED' (Rel $cfgPath) `
            'h24_cadence missing - W2 chua land => carry-age cadence UNRESOLVED, measuring NOTHING (no default is assumed: a hardcoded cadence would violate H24 single-source)' `
            'W2: add h24_cadence { light_every, deep_every, jump_on_class_repeat }'
    }
    elseif ($null -eq $cfg.h24_cadence.light_every) {
        Write-Flag 'MED' (Rel $cfgPath) `
            'h24_cadence present but sub-key light_every missing - carry-age uses light_every as M, measuring NOTHING' `
            'W2: add h24_cadence.light_every (the light-audit cadence a carry must not outlive)'
    }
    else {
        $CadenceM = [int]$cfg.h24_cadence.light_every
        Write-Host ("  M = h24_cadence.light_every = {0} (read from config, not hardcoded)" -f $CadenceM)
    }
}

$handoffPath = Join-Path $RepoRoot 'docs\HANDOFF.md'
if (-not (Test-Path $handoffPath)) {
    Write-Host '  (no docs/HANDOFF.md - no carry surface, skip)' -ForegroundColor DarkGray
}
else {
    $raw = Get-Content -Path $handoffPath -Raw -Encoding UTF8
    # "NEXT anh" / "NEXT em" are pure ASCII (no diacritics) -> safe as a literal here.
    # S151 fix (lead-gap FLAG-1 HIGH, ring2 re-implemented + confirmed): the unanchored
    # literal cut a segment at EVERY occurrence of the phrase - including MID-PROSE
    # mentions (HANDOFF item (20) contains "NEXT anh #11" inside a sentence), which
    # split the current segment so carryLines[0] shared nothing with carryLines[1] and
    # every streak broke at 1 => the carry-age net went silent while printing [ok].
    # Anchor to REAL segment headers only: line-start bold "**NEXT anh/em" with an
    # optional emoji marker (e.g. the red dot). The marker is matched as "any run of
    # non-ASCII chars" [^\x00-\x7F]+ so this .ps1 source stays pure-ASCII (gotcha #30)
    # and survives a future marker-emoji change; \xNN is interpreted by .NET at match time.
    $marks = [regex]::Matches($raw, '(?m)^\*\*(?:[^\x00-\x7F]+\s*)?NEXT\s+(?:anh|em)\b')
    $segs = @()
    # S185 fix (WAL "vung-cam :892" / HANDOFF:58): everything BEFORE marks[0] was never
    # scanned - yet the top-of-file blocks (RE-STAMP carry list, top Carry blocks) hold
    # most LIVE [carry:] slugs, so the carry-age net was silent exactly where the debt
    # lives (12+ slugs swallowed) while printing [ok]. Prepend the pre-marker region as
    # segment 0; with 0 markers the whole file becomes the single segment (was: empty).
    if ($marks.Count -eq 0) { if ($raw.Length -gt 0) { $segs += $raw } }
    elseif ($marks[0].Index -gt 0) { $segs += $raw.Substring(0, $marks[0].Index) }
    for ($i = 0; $i -lt $marks.Count; $i++) {
        $start = $marks[$i].Index
        $end = if ($i + 1 -lt $marks.Count) { $marks[$i + 1].Index } else { $raw.Length }
        $segs += $raw.Substring($start, $end - $start)
    }
    # Key charset excludes '<' so the FORMAT-SPEC literal "[carry:<slug>]" (prose in
    # HANDOFF describing the convention) is never counted as a real key -- a detector
    # that flags the sentence DEFINING its own pattern is the self-reference trap.
    $carryRx = '\[carry:([a-z0-9][a-z0-9._-]*)\]'
    $carryLines = @()
    $carrySegIdx = @()   # L7-bolt: which segment each carry-line came from
    for ($si = 0; $si -lt $segs.Count; $si++) {
        $ks = @()
        foreach ($cm in [regex]::Matches($segs[$si], $carryRx)) { $ks += $cm.Groups[1].Value }
        if ($ks.Count -gt 0) { $carryLines += , (@($ks | Select-Object -Unique)); $carrySegIdx += $si }
    }
    Write-Host ("  HANDOFF logic-segments (pre-marker + NEXT anh/em) = {0} ; of those, carry-lines = {1}" -f `
            $segs.Count, $carryLines.Count)

    # --- L7-BOLT (C-2 @S185, medicine of broadcast 2026-08-05 "Lop 7", Ro C) ---------
    # This net anchors on TWO human-typed shapes: the '**NEXT anh/em' segment header and
    # the '[carry:slug]' stamp. Only the NEWEST carry-bearing segment can hold a live
    # streak, so if the newest block is typed differently (bold dropped, stamp cased
    # '[Carry:'), the anchor SLIDES DOWN to an older block and every streak is then
    # measured against history the repo already replaced -- while the output keeps its
    # confident per-key shape ("streak=3 < M=6"). That is the broadcast's ca verbatim:
    # the machine slid past the newest lines and reported precise, wrong numbers.
    #
    # BOLT, read from an INDEPENDENT source: the session milestone of the anchored
    # segment vs the newest milestone in the SAME document parsed WITHOUT any of the two
    # glyphs (plain S<N> scan over the raw text). Both readings come off the same file
    # but through disjoint parse paths, so no single glyph change can move both. Off by
    # more than ONE step => print "chua do duoc" and emit NO streak number.
    # WHY the doc's own newest S and not the session counter: the question this net asks
    # is "is my anchor at the FRONT of this document", which is a property of the
    # document. The counter would answer a different question (is the document current),
    # and picking a tolerance for THAT is an owner number -- not invented here.
    $SMilestoneRx = '(?<![A-Za-z0-9])S(\d+)(?![\d])'
    $docMaxS = 0
    foreach ($sm in [regex]::Matches($raw, $SMilestoneRx)) {
        $sn = [int]$sm.Groups[1].Value; if ($sn -gt $docMaxS) { $docMaxS = $sn }
    }
    $anchorMaxS = 0
    if ($carrySegIdx.Count -gt 0) {
        foreach ($sm in [regex]::Matches($segs[$carrySegIdx[0]], $SMilestoneRx)) {
            $sn = [int]$sm.Groups[1].Value; if ($sn -gt $anchorMaxS) { $anchorMaxS = $sn }
        }
    }
    $carryAnchorLive = $true
    $carryAnchorGap = 0
    if ($carryLines.Count -gt 0 -and $docMaxS -gt 0 -and $anchorMaxS -gt 0) {
        $carryAnchorGap = $docMaxS - $anchorMaxS
        if ($carryAnchorGap -gt 1) { $carryAnchorLive = $false }
    }

    if ($carryLines.Count -eq 0) {
        Write-Host '  (0 carry-line - no [carry:<slug>] stamped yet, nothing to age)' -ForegroundColor DarkGray
    }
    elseif ($docMaxS -eq 0 -or $anchorMaxS -eq 0) {
        Write-Host ("  CHUA DO DUOC (L7-bolt): no S<N> milestone readable ({0} in doc / {1} in anchored segment) - cannot tell whether the anchor is at the front of the file, so NO streak number is emitted" -f $docMaxS, $anchorMaxS) -ForegroundColor Yellow
        Write-InformFlag 'docs/HANDOFF.md' `
            'L7-bolt: carry-age anchor liveness UNMEASURABLE (no S<N> milestone in the doc and/or in the anchored segment) - carry-age measured NOTHING this run (chua do duoc, NOT clean)' `
            'keep an S<N> session milestone in the newest carry block so the anchor can be proven to sit at the front of the file'
    }
    elseif (-not $carryAnchorLive) {
        Write-Host ("  CHUA DO DUOC (L7-bolt): carry anchor sits at S{0} while the document's newest milestone is S{1} ({2} steps behind) -> the '**NEXT' / '[carry:' shape anchor has SLID off the newest block. Streak numbers WITHHELD ({3} key(s) would have been scored against replaced history)." -f `
                $anchorMaxS, $docMaxS, $carryAnchorGap, $carryLines[0].Count) -ForegroundColor Yellow
        Write-InformFlag 'docs/HANDOFF.md' `
            ("L7-bolt: carry-age anchor DEAD - anchored segment is S{0}, doc newest is S{1} (gap {2} > 1); {3} key(s) NOT scored, carry-age measured NOTHING this run (chua do duoc, NOT clean)" -f $anchorMaxS, $docMaxS, $carryAnchorGap, $carryLines[0].Count) `
            "restore the newest block's '**NEXT anh/em' header and lower-case '[carry:<slug>]' stamps so the newest segment is the anchored one again"
    }
    else {
        Write-Host ("  [ok] L7-bolt: carry anchor S{0} vs doc newest S{1} (gap {2} <= 1) - anchor is at the front of the file, streaks below are measured" -f `
                $anchorMaxS, $docMaxS, $carryAnchorGap) -ForegroundColor DarkGray
        foreach ($k in $carryLines[0]) {
            $n = 0
            for ($i = 0; $i -lt $carryLines.Count; $i++) {
                if ($carryLines[$i] -contains $k) { $n++ } else { break }
            }
            if ($null -eq $CadenceM) {
                Write-Host ("    [inform] carry '{0}' streak={1} carry-line(s) ; M unresolved -> NO aged/not-aged verdict" -f $k, $n) -ForegroundColor DarkGray
            }
            elseif ($n -ge $CadenceM) {
                Write-Flag 'LOW' ('docs/HANDOFF.md:5') `
                    ("gap-carry-aged [INFORM]: carry '{0}' alive across {1} consecutive carry-lines (>= M={2}) - owner may be holding it deliberately" -f $k, $n, $CadenceM) `
                    ("close it, or re-scope it; INFORM-only - no action forced")
            }
            else {
                Write-Host ("    [ok] carry '{0}' streak={1} < M={2}" -f $k, $n, $CadenceM) -ForegroundColor DarkGray
            }
        }
    }
}

# ---------------------------------------------------------------------------
# H25-closeout-ritual (GAP-2) : did the last 3 session-close commits each leave a
# full ritual trace? A close that skips STATUS / HANDOFF / a NEW session-log / an
# agent-memory delta is silent governance drift (the memory-loss class).
#
# WINDOW, not per-commit (T4a, measured): the ritual delta does NOT sit inside the
# closeout commit alone. The Stop-hook 'wal: flush' carries agent-memory deltas in
# EARLIER commits, and STATUS/HANDOFF are often bumped a commit or two before the
# close. Scoring the closeout commit by itself FAILs healthy data 2/3. So each close
# is scored over the UNION of every commit since the PREVIOUS close: the half-open
# range (prev-close .. this-close], via git log --name-only.
#
# The NEW-session book uses --diff-filter=A (an ADDED path): editing an old session
# log is not opening this session's log. The other three books accept any touch
# (STATUS/HANDOFF/diaries are appended, not recreated).
#
# FOLD-PER-LABEL (owner decision S137; was HONEST LIMIT): consecutive closes whose
# S-label ranges OVERLAP (batch close 'S131-S132' + tail-supplement 'S132') fold into
# ONE close-group, scored once over the union window that opens at the previous
# group's newest close. This is the per-session-label folding the canonical spec
# baseline (phep-3 union-per-label) called for; it retires the tail-adjacency
# false-positive class (W3/S132 - FP verified 3/3 on real git data in S136).
# GUARD (owner-set, same decision): folding must not become a ritual-evasion lane --
# a group spanning >= 3 sessions prints an INFORM line (not a flag) so a batch label
# that swallows many sessions stays visible to the owner.
#
# git-based, so it honours -RepoRoot (fault-injection runs on a temp git tree). A
# RepoRoot that is not a git work-tree SKIPs with a reason; a probe never crashes.
# ---------------------------------------------------------------------------
Write-Section 'H25-closeout-ritual (GAP-2)'

# C13 hook (carve-out liveness, C-8 @S185): counts close-groups this section ACTUALLY
# scored. 0 = the grid ran but measured nothing (skip branch) = "chet lang". Read by
# the C13 section far below; kept here so the number is produced by the grid itself and
# not re-derived (a second derivation would be a second definition).
$script:H25Scored = 0

# Subject shape of a session-close commit. Held in a variable (used by -match on git
# SUBJECTS, never on any scanned file) so no prose copy exists to self-trip a matcher.
$CloseoutSubjectRx = '^\[CLAUDE\] Docs: S\d+.*(?:closeout|session-end)'

$closeouts = @()
$gitTree = Test-Path (Join-Path $RepoRoot '.git')
if ($gitTree) {
    try {
        $logRaw = & git -C $RepoRoot log --format='%H|%s' --max-count=400
    } catch { $logRaw = $null }
    if ($logRaw) {
        foreach ($ln in $logRaw) {
            if ([string]::IsNullOrWhiteSpace($ln)) { continue }
            $parts = $ln -split '\|', 2
            if ($parts.Count -lt 2) { continue }
            if ($parts[1] -match $CloseoutSubjectRx) {
                $lo = $null; $hi = $null; $lab = 'S?'
                if ($parts[1] -match 'S(\d+)(?:\s*-\s*S(\d+))?') {
                    $lo = [int]$Matches[1]
                    $hi = if ($Matches[2]) { [int]$Matches[2] } else { $lo }
                    if ($hi -lt $lo) { $swp = $lo; $lo = $hi; $hi = $swp }
                    $lab = if ($hi -ne $lo) { ('S{0}-S{1}' -f $lo, $hi) } else { ('S{0}' -f $lo) }
                }
                $closeouts += [pscustomobject]@{ Hash = $parts[0]; Short = $parts[0].Substring(0, 7); Label = $lab; Lo = $lo; Hi = $hi }
            }
        }
    }
}

if (-not $gitTree) {
    Write-Host '  [skip] RepoRoot is not a git work-tree - cannot score closeouts' -ForegroundColor DarkGray
}
elseif ($closeouts.Count -eq 0) {
    Write-Host '  [skip] no commit matches the closeout subject shape - nothing to score' -ForegroundColor DarkGray
}
else {
    # Fold consecutive closes with OVERLAPPING S-label ranges into close-groups
    # (newest-first order preserved; 'S?' labels never fold). Owner decision S137.
    $closeGroups = @()
    foreach ($c in $closeouts) {
        $joined = $false
        if ($closeGroups.Count -gt 0 -and $null -ne $c.Lo) {
            $g = $closeGroups[$closeGroups.Count - 1]
            if ($null -ne $g.Lo -and ($c.Lo -le $g.Hi) -and ($g.Lo -le $c.Hi)) {
                $g.Members += $c
                if ($c.Lo -lt $g.Lo) { $g.Lo = $c.Lo }
                if ($c.Hi -gt $g.Hi) { $g.Hi = $c.Hi }
                $joined = $true
            }
        }
        if (-not $joined) {
            $closeGroups += [pscustomobject]@{ Tip = $c; Members = @($c); Lo = $c.Lo; Hi = $c.Hi }
        }
    }

    $take = [Math]::Min(3, $closeGroups.Count)
    Write-Host ("  closeouts found: {0} in {1} close-group(s) ; scoring {2} most-recent group(s) (label-folded union-window per group)" -f $closeouts.Count, $closeGroups.Count, $take)
    for ($i = 0; $i -lt $take; $i++) {
        $gcur = $closeGroups[$i]
        $cur = $gcur.Tip
        $glab = if ($null -eq $gcur.Lo) { 'S?' } elseif ($gcur.Hi -ne $gcur.Lo) { ('S{0}-S{1}' -f $gcur.Lo, $gcur.Hi) } else { ('S{0}' -f $gcur.Lo) }
        $span = if ($null -eq $gcur.Lo) { 1 } else { $gcur.Hi - $gcur.Lo + 1 }
        if ($span -ge 3) {
            Write-Host ("    [INFORM] batch-label guard: group {0} folds {1} sessions into one ritual window - a batch label must not become a ritual-evasion lane (threshold >=3, owner-set S137)" -f $glab, $span) -ForegroundColor Yellow
        }
        if ($i + 1 -ge $closeGroups.Count) {
            Write-Host ("    [skip] {0} {1}: no earlier close to open the window (oldest close in history) - not scored, not flagged" -f $cur.Short, $glab) -ForegroundColor DarkGray
            continue
        }
        $prev = $closeGroups[$i + 1].Tip
        $script:H25Scored++   # C13 hook: this group IS scored (not skipped)
        $range = ("{0}..{1}" -f $prev.Hash, $cur.Hash)

        $union = @()
        try { $u = & git -C $RepoRoot log --name-only --pretty=format: $range } catch { $u = $null }
        foreach ($p in $u) { if (-not [string]::IsNullOrWhiteSpace($p)) { $union += $p.Trim() } }

        $newSess = @()
        try { $ns = & git -C $RepoRoot log --diff-filter=A --name-only --pretty=format: $range '--' 'docs/changelog/sessions/' } catch { $ns = $null }
        foreach ($p in $ns) { if (-not [string]::IsNullOrWhiteSpace($p)) { $newSess += $p.Trim() } }

        $missing = @()
        if (-not ($union -contains 'docs/STATUS.md'))  { $missing += 'docs/STATUS.md' }
        if (-not ($union -contains 'docs/HANDOFF.md')) { $missing += 'docs/HANDOFF.md' }
        if ($newSess.Count -eq 0)                      { $missing += 'docs/changelog/sessions/*(NEW,--diff-filter=A)' }
        if (@($union | Where-Object { $_ -match '^\.claude/agent-memory/' }).Count -eq 0) { $missing += '.claude/agent-memory/**' }

        if ($missing.Count -eq 0) {
            Write-Host ("    [ok] {0} {1}: union ({2}..{3}] has all 4 ritual books" -f $cur.Short, $glab, $prev.Short, $cur.Short) -ForegroundColor DarkGray
        }
        else {
            Write-Flag 'MED' ("git:{0} ({1})" -f $cur.Short, $glab) `
                ("closeout-ritual gap: window ({0}..{1}] missing {2} of 4 books -> {3}" -f $prev.Short, $cur.Short, $missing.Count, ($missing -join ', ')) `
                'the session-close (or its window since the prior close) must touch STATUS + HANDOFF + a NEW session-log + an agent-memory diary'
        }
    }
}

# ---------------------------------------------------------------------------
# H25-role-notebook (GAP-3) : every role that ACTUALLY RAN must leave a diary
# (.claude/agent-memory/<role>/MEMORY.md, byte>0). A role defined-but-never-spawned
# is NOT a defect, so the check is gated on evidence-of-run and measures CONTENT not
# NAME (S122): a sub-file whose prefix is not an exact roster role is LISTED as
# UNMAPPED, never flagged (no name-guessing). A zero-byte diary IS flagged
# (anti-Goodhart: an empty notebook is not a notebook; touching a file to green the
# check must still fail). Byte size via (Get-Item).Length -- Get-Content-count is
# FORBIDDEN (a no-BOM file miscounts VN text x2-3, bug E-010/S130).
# ---------------------------------------------------------------------------
Write-Section 'H25-role-notebook (GAP-3)'

$agentsDir = Join-Path $RepoRoot '.claude\agents'
$roster = @()
if (Test-Path $agentsDir) {
    $roster = @(Get-ChildItem -Path $agentsDir -Filter *.md -File -ErrorAction SilentlyContinue |
            Where-Object { $_.BaseName -ne 'README' } | ForEach-Object { $_.BaseName })
}
$rosterSet = @{}
foreach ($r in $roster) { $rosterSet[$r] = $true }

if ($roster.Count -eq 0) {
    Write-Host '  [skip] no .claude/agents/*.md roster - nothing to check' -ForegroundColor DarkGray
}
else {
    # Evidence-of-run: scan runs/*/ for per-role artifacts and map filename prefix to a roster role.
    # Three-step greedy parse: 'sub-<role>-<numeric-idx>.md', else 'sub-<rest>.md', else '<role>-return.md'.
    # @S159 FIX (ctx-audit FLAG-6): the filter used to be the literal glob 'sub-*.md', so the trio
    # returns (harness-eval-return.md / harness-refine-return.md / harness-audit-return.md) were
    # INVISIBLE to this detector. Measured on the S159 bookend: 9 spawns ran, the glob saw 6 -
    # it dropped exactly the 3 most expensive lanes. A grid that cannot see the priciest lanes
    # reports 'clean' for the very roles most likely to be missing. Same class as the standing
    # carry 'C11(b) filter'; that carry now has a real case behind it, not a hypothesis.
    $runsDir = Join-Path $RepoRoot '.claude\workflows\runs'
    $ran = @{}        # role -> ran (exact roster match)
    $unmapped = @{}   # prefix -> seen (not an exact roster role)
    if (Test-Path $runsDir) {
        $subs = @(Get-ChildItem -Path $runsDir -Recurse -File -ErrorAction SilentlyContinue |
                  Where-Object { $_.Name -match '^(sub-.+|.+-return)\.md$' })
        foreach ($s in $subs) {
            $cand = $null
            if ($s.Name -match '^sub-(.+)-\d+\.md$') { $cand = $Matches[1] }
            elseif ($s.Name -match '^sub-(.+)\.md$') { $cand = $Matches[1] }
            elseif ($s.Name -match '^(.+)-return\.md$') { $cand = $Matches[1] }
            if ($null -eq $cand) { continue }
            if ($rosterSet.ContainsKey($cand)) { $ran[$cand] = $true } else { $unmapped[$cand] = $true }
        }
    }

    # has-run(role) = a mapped sub-file OR a diary directory already on disk (any size).
    $memRoot = Join-Path $RepoRoot '.claude\agent-memory'
    $okCount = 0; $flagged = 0; $inert = 0
    foreach ($role in ($roster | Sort-Object)) {
        $diary = Join-Path (Join-Path $memRoot $role) 'MEMORY.md'
        $diaryExists = Test-Path -LiteralPath $diary
        $hasRun = ($ran.ContainsKey($role)) -or $diaryExists
        if (-not $hasRun) {
            $inert++
            continue
        }
        $bytes = if ($diaryExists) { (Get-Item -LiteralPath $diary).Length } else { -1 }
        if ($diaryExists -and $bytes -gt 0) {
            $okCount++
        }
        else {
            $why = if (-not $diaryExists) { 'MISSING' } else { '0-byte (empty notebook)' }
            Write-Flag 'MED' ("agent-memory/{0}/MEMORY.md" -f $role) `
                ("role-notebook gap: role '{0}' has run-evidence but its diary is {1}" -f $role, $why) `
                ("write a non-empty agent-memory/{0}/MEMORY.md (harvest the role slice at session-end)" -f $role)
            $flagged++
        }
    }
    Write-Host ("  roster={0} ; diaries-ok={1} ; flagged={2} ; inert(defined-not-run)={3}" -f $roster.Count, $okCount, $flagged, $inert)
    if ($unmapped.Count -gt 0) {
        $ulist = ($unmapped.Keys | Sort-Object) -join ', '
        Write-Host ("  UNMAPPED sub-file prefixes ({0}) [INFORM, not flagged - measure content not name, S122]:" -f $unmapped.Count) -ForegroundColor DarkGray
        Write-Host ("    {0}" -f $ulist) -ForegroundColor DarkGray
    }
}

# ---------------------------------------------------------------------------
# C6 - cite-2-tier (INFORM-only, NEW; count SEPARATE, NOT folded into TOTAL)
#
# Two-tier citation check over a NARROW scope. A "cite" is a path:line token whose
# path ends in md|ps1|js|cs|ts|tsx. Tier-1: the file does not resolve -> cite-dead-file.
# Tier-2: it resolves but the cited line > EOF (ReadAllLines.Length) -> cite-line-past-eof.
# Both LOW (suite convention: an un-triaged net reads by judgement on day one; MED-noise
# + fold-into-TOTAL are a later, owner-gated call after triage).
#
# SCOPE (deliberately narrow, spec C2): docs/governance/*.md TOP-LEVEL only
# (adap-reports/ EXCLUDED -- ~90 lineage cites there are frozen history, not live
# claims) + .claude/commands/*.md + .claude/agents/*.md.
#
# THREE EXCLUSIONS, all measured on disk before shipping:
#   (a) QUOTED cites -- inline-backtick via Test-Quoted (S123 citation-trap law: a
#       prose matcher MUST use the enclosure use/mention discriminator) AND fenced
#       ``` blocks via a fence-state toggle. MEASURED: 172/185 cites in scope sit
#       inside backticks/fences (fable-real-runbook.md tables + code fence :103-109);
#       scanning them bare would be ~172 false dead-file flags.
#   (b) LINEAGE: the adap-reports/ dir (dir-exclude) + any line carrying the frozen
#       marker U+1F9CA (built by code point, gotcha #30). fable-real-runbook.md:88 is
#       exactly such a line (cite next to the retired-marker note). Declared: lineage
#       OUTSIDE these two proxies accepts a first-run false positive; tune after triage.
#   (c) SELF-DEF: this script is .ps1 + already in ExcludeExact; the run's spec + sub-MD
#       live under .claude/workflows/runs/ which is already an ExcludeDirFragment -- so
#       both are out of scope by construction AND re-checked via Test-Excluded per file.
#
# RESOLUTION (spec: repo-rel + familiar roots .claude/, docs/): try repo-rel exact,
# then .claude/<path> and docs/<path> prefix-joins, then -- for a BARE basename -- a
# recursive basename index built once over .claude/ + docs/. That index is why a bare
# hmw.js (lives at .claude/workflows/hmw.js) or reviewer.md (.claude/agents/reviewer.md)
# resolves instead of false-flagging (measured: 172/185 cites are bare basenames).
#
# HEADER LIMITS (declared, spec m-1):
#   (a) soft-drift WITHIN eof is invisible (a stale :5 that should be :7 still resolves
#       and is <= EOF -> not caught; the S108 +-1/2 class);
#   (b) multi-root resolve can MASK a dead cite when a same-basename file exists at
#       another root (index returns the first match; Ambiguous is noted in the flag);
#   (c) a range cite file.md:12-15 is checked at its FIRST line (12) only.
# ---------------------------------------------------------------------------
Write-Section 'C6 - cite-2-tier (INFORM-only)'

$FROZEN_MARK = U @(0xD83E, 0xDDCA)      # U+1F9CA frozen/lineage marker (surrogate pair)
$FenceTriple = U @(0x60, 0x60, 0x60)    # ``` (ASCII backticks; built to avoid quoting ambiguity)
$FenceRx = '^\s{0,3}' + $FenceTriple

$c6Scope = @()
$govTop = Join-Path $RepoRoot 'docs\governance'
if (Test-Path $govTop) { $c6Scope += Get-ChildItem -Path $govTop -Filter *.md -File -ErrorAction SilentlyContinue }
$cmdDir = Join-Path $RepoRoot '.claude\commands'
if (Test-Path $cmdDir) { $c6Scope += Get-ChildItem -Path $cmdDir -Filter *.md -File -ErrorAction SilentlyContinue }
$agtDir = Join-Path $RepoRoot '.claude\agents'
if (Test-Path $agtDir) { $c6Scope += Get-ChildItem -Path $agtDir -Filter *.md -File -ErrorAction SilentlyContinue }
$c6Scope = @($c6Scope | Where-Object { -not (Test-Excluded $_.FullName) })

# Recursive basename index over the two familiar roots for BARE-basename resolution.
$citeBasenameIndex = @{}
foreach ($rt in @('.claude', 'docs')) {
    $rtPath = Join-Path $RepoRoot $rt
    if (-not (Test-Path $rtPath)) { continue }
    Get-ChildItem -Path $rtPath -Recurse -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Extension -imatch '^\.(md|ps1|js|cs|ts|tsx)$' -and $_.FullName -notmatch '[\\/](bin|obj|node_modules)[\\/]' } |
        ForEach-Object {
            $bn = $_.Name
            if (-not $citeBasenameIndex.ContainsKey($bn)) { $citeBasenameIndex[$bn] = New-Object System.Collections.Generic.List[string] }
            $citeBasenameIndex[$bn].Add($_.FullName) | Out-Null
        }
}

function Resolve-Cite {
    param([string]$Path)
    $norm = $Path -replace '/', '\'
    foreach ($c in @(
            (Join-Path $RepoRoot $norm),
            (Join-Path $RepoRoot (Join-Path '.claude' $norm)),
            (Join-Path $RepoRoot (Join-Path 'docs' $norm)))) {
        if (Test-Path -LiteralPath $c -PathType Leaf) {
            return [pscustomobject]@{ Exists = $true; File = $c; Ambiguous = $false }
        }
    }
    if ($norm -notmatch '[\\/]') {
        $bn = [System.IO.Path]::GetFileName($norm)
        if ($citeBasenameIndex.ContainsKey($bn)) {
            $lst = $citeBasenameIndex[$bn]
            return [pscustomobject]@{ Exists = $true; File = $lst[0]; Ambiguous = ($lst.Count -gt 1) }
        }
    }
    return [pscustomobject]@{ Exists = $false; File = $null; Ambiguous = $false }
}

# cite = <path>.<ext>:<line>. Leading '.' allowed (so .claude/... paths resolve). Range
# :12-15 captured at first line only (limit c). Lookbehind stops mid-word matches.
$c6CiteRx = '(?<![A-Za-z0-9])(\.?[A-Za-z0-9_][A-Za-z0-9_./\\-]*\.(?:md|ps1|js|cs|tsx?)):(\d+)'
$c6EofCache = @{}
$c6Flags = 0
$c6Scanned = 0

foreach ($f in $c6Scope) {
    $lines = Get-Content -Path $f.FullName -Encoding UTF8 -ErrorAction SilentlyContinue
    if ($null -eq $lines) { continue }
    $c6Scanned++
    $inFence = $false
    for ($i = 0; $i -lt $lines.Count; $i++) {
        $line = $lines[$i]
        if ($line -match $FenceRx) { $inFence = -not $inFence; continue }   # fence toggle: skip the ``` line
        if ($inFence) { continue }                                          # inside ``` block: mentioned, not used
        if ($line.Contains($FROZEN_MARK)) { continue }                      # lineage: frozen-marker line
        foreach ($m in [regex]::Matches($line, $c6CiteRx)) {
            if (Test-Quoted $line $m.Index) { continue }                    # inline `...`: mentioned, not used
            $citePath = $m.Groups[1].Value
            $citeLine = [int]$m.Groups[2].Value
            $r = Resolve-Cite $citePath
            if (-not $r.Exists) {
                Write-InformFlag ("{0}:{1}" -f (Rel $f.FullName), ($i + 1)) `
                    ("cite-dead-file: '{0}:{1}' resolves to no file (repo-rel + .claude/ + docs/ + basename-index)" -f $citePath, $citeLine) `
                    'fix the path or add the missing file (range cites are checked at their first line)'
                $c6Flags++
            }
            else {
                if (-not $c6EofCache.ContainsKey($r.File)) {
                    try { $c6EofCache[$r.File] = [System.IO.File]::ReadAllLines($r.File).Length }
                    catch { $c6EofCache[$r.File] = -1 }
                }
                $eof = $c6EofCache[$r.File]
                if ($eof -ge 0 -and $citeLine -gt $eof) {
                    $amb = if ($r.Ambiguous) { ' (basename ambiguous across roots -- may mask a dead cite)' } else { '' }
                    Write-InformFlag ("{0}:{1}" -f (Rel $f.FullName), ($i + 1)) `
                        ("cite-line-past-eof: '{0}:{1}' but {2} has {3} lines{4}" -f $citePath, $citeLine, (Rel $r.File), $eof, $amb) `
                        'fix the line number (target file is shorter) or update the cite'
                    $c6Flags++
                }
            }
        }
    }
}
Write-Host ("  C6 scanned {0} file(s) (governance-top + commands + agents; adap-reports excluded)" -f $c6Scanned)
Write-Host ("  C6 cite-2-tier flags = {0} [INFORM-only, LOW, NOT folded into TOTAL baseline]" -f $c6Flags) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# H24-4 - pending-flip (INFORM-only, NEW; count SEPARATE, NOT folded into TOTAL)
#
# Cross-source staleness: a HANDOFF "waiting-for-owner" note whose referenced mark has
# ALREADY flipped (stamped Active/Active-High, or RESOLVED) in ACTIVE-MARKS.md. The
# HANDOFF still says "waiting" while the ledger says "decided" => pending-flip-stale.
#
# SOURCE A = docs/HANDOFF.md, CURRENT SEGMENT ONLY. Boundary = the FIRST line that
# STARTS WITH "**Prev S" (regex ^, NOT substring). M-1 (measured): the current segment
# carries "... segment Prev S134 ..." in the MIDDLE of a line -- a substring match would
# cut the segment early and drop live lines; the ^-anchor does not. STATUS.md is OUT of
# scope v1 (M-2): its In-Progress section is now only S117/S113 lineage -> scanning it
# is background noise; widening is a post-triage call.
#
# MATCH (M-3): split each line on separators " <U+00B7> " (space-middot-space) or " ; "
# -> logic fragments. A waiting token (CHO GAT | CHO ANH | cho-ky, built by code point)
# AND a mark code (RC-... signature OR [carry:slug]) must sit in the SAME fragment. A
# token inside `...`/fence is MENTIONED not used (Test-Quoted + fence).
#
# SOURCE B = .claude/governance/ACTIVE-MARKS.md. The mark's OWN table row (first cell)
# decides status: Active/Active-High -> stamped ; SUPERSEDED/DISABLE -> IM ; a RESOLVED
# note mentioning it -> resolved. stamped|resolved => FLAG ; superseded|not-found => IM.
#
# LIVE first-run EXPECTATION = 0 flags by design (measured: current segment has 0
# waiting tokens). Value is in FUTURE sessions. Declared out-of-reach class: a proposal
# that landed in CODE but was never marked (S137) is a human-eye H24 view audit, not
# machine-visible here.
# ---------------------------------------------------------------------------
Write-Section 'H24-4 - pending-flip (INFORM-only)'

$h244Flags = 0
$handoffP = Join-Path $RepoRoot 'docs\HANDOFF.md'
$marksP = Join-Path $RepoRoot '.claude\governance\ACTIVE-MARKS.md'
if (-not (Test-Path $handoffP)) {
    Write-Host '  [skip] no docs/HANDOFF.md - no pending surface' -ForegroundColor DarkGray
}
elseif (-not (Test-Path $marksP)) {
    Write-Host '  [skip] no .claude/governance/ACTIVE-MARKS.md - cannot cross-check mark status' -ForegroundColor DarkGray
}
else {
    $clsO = '[' + (U @(0x1EDC, 0x1EDD)) + ']'   # O-horn-grave upper/lower
    $clsA = '[' + (U @(0x1EAC, 0x1EAD)) + ']'   # A-circumflex-dot upper/lower
    $clsY = '[' + (U @(0x00FD, 0x00DD)) + ']'   # y-acute lower/upper
    $WaitRx = '[Cc][Hh]' + $clsO + '[ \-]([Gg]' + $clsA + '[Tt]|[Aa][Nn][Hh]|[Kk]' + $clsY + ')'
    $MidSep = ' ' + (U @(0x00B7)) + ' '
    $SepRx = [regex]::Escape($MidSep) + '| ; '
    $CodeRx = '(?:RC-[A-Za-z0-9][A-Za-z0-9-]*)|(?:\[carry:[a-z0-9][a-z0-9._-]*\])'
    $BacktickCh = U @(0x60)

    $script:marksLines = @(Get-Content -Path $marksP -Encoding UTF8 -ErrorAction SilentlyContinue)

    function Get-MarkStatus {
        param([string]$Code)
        $ownRowRx = '^\|\s*' + $BacktickCh + [regex]::Escape($Code) + $BacktickCh
        $status = 'none'
        foreach ($ml in $script:marksLines) {
            if ($ml -match $ownRowRx) {
                if ($ml -match '(?i)SUPERSEDED|DISABLE') { return 'superseded' }
                if ($ml -match 'Active') { $status = 'stamped' }
            }
            elseif (($ml -match '(?i)RESOLVED') -and ($ml.Contains($Code))) {
                if ($status -eq 'none') { $status = 'resolved' }
            }
        }
        return $status
    }

    $handLines = @(Get-Content -Path $handoffP -Encoding UTF8 -ErrorAction SilentlyContinue)
    $boundary = $handLines.Count
    for ($i = 0; $i -lt $handLines.Count; $i++) {
        if ($handLines[$i] -match '^\*\*Prev S') { $boundary = $i; break }
    }
    Write-Host ("  HANDOFF current-segment = lines 1..{0} (boundary '**Prev S' at line {1})" -f $boundary, ($boundary + 1))

    $inFence = $false
    for ($i = 0; $i -lt $boundary; $i++) {
        $line = $handLines[$i]
        if ($line -match $FenceRx) { $inFence = -not $inFence; continue }
        if ($inFence) { continue }
        foreach ($wm in [regex]::Matches($line, $WaitRx)) {
            if (Test-Quoted $line $wm.Index) { continue }
            # fragment enclosing the token = span between the nearest separators.
            $left = 0; $right = $line.Length
            foreach ($sm in [regex]::Matches($line, $SepRx)) {
                if (($sm.Index + $sm.Length) -le $wm.Index) { $left = $sm.Index + $sm.Length }
                elseif ($sm.Index -ge ($wm.Index + $wm.Length)) { $right = $sm.Index; break }
            }
            $frag = $line.Substring($left, $right - $left)
            foreach ($cm in [regex]::Matches($frag, $CodeRx)) {
                $st = Get-MarkStatus $cm.Value
                if ($st -eq 'stamped' -or $st -eq 'resolved') {
                    Write-InformFlag ("docs/HANDOFF.md:{0}" -f ($i + 1)) `
                        ("pending-flip-stale: current segment still waits on '{0}' but ACTIVE-MARKS has it {1}" -f $cm.Value, $st) `
                        'close/re-scope the HANDOFF waiting item, or re-open the mark if the wait is real'
                    $h244Flags++
                }
            }
        }
    }
    Write-Host ("  H24-4 pending-flip flags = {0} [INFORM-only, LOW, NOT folded; 0 = designed first-run]" -f $h244Flags) -ForegroundColor DarkGray
}

# ---------------------------------------------------------------------------
# H24-5 - counter-ancestor-classify (INFORM-only, NEW; count SEPARATE, NOT in TOTAL)
#
# WHAT: a static, read-only replay of the .session-counter.json contract v2 clause
# "fail_loud_on_regress" (adopted S141, hub template 58e28bae C-1). The RUNTIME owner of
# that clause is the /session-start 2.1.8(b) ritual -- this detector does NOT replace it
# and could not: the ritual is the half with teeth (it FAIL-LOUDs to the owner mid-run).
# What a detector adds is a between-sessions eye that can be pointed at ANY tree.
#
# THE CLASSIFICATION (contract order, verbatim shape):
#   1. last_ticked_head reachable from HEAD (git merge-base --is-ancestor, exit 0)
#      -> OK-reachable. No flag.
#   2. NOT reachable BUT the object still EXISTS (git cat-file -t = 'commit')
#      -> SQUASH-BENIGN: a trace line, ZERO flags. This is the expected steady state --
#      every closeout squashes the session's wal: commits, so the head captured at the
#      previous tick routinely drops out of history. The 3 historical FAIL-LOUDs recorded
#      in history[] (S127/S133/S139) all reclassify here; that IS contract v2's purpose.
#   3. object MISSING (cat-file fails) -> flag object-missing. The anti-tamper half: a
#      head that no longer exists AT ALL was not squashed out, it was rewritten away.
#   [M1] counter-regress, an INDEPENDENT axis and DEFAULT-ON: the stored counter must
#   never sit BELOW a counter at which an audit already ran (last_audit.light_at_counter
#   / deep_at_counter). Below = the file was rolled back or hand-edited, and a silent
#   reset would hide the cadence gap -- the exact thing H24 exists to catch.
#
# [M2] DAY-ONE = INFORM-SINK. Every H24-5 hit goes through Write-InformFlag: its own
# [DETECTOR] line, counted in $script:InformCount, NEVER folded into TOTAL. Same
# reasoning as C6/H24-4 (see the Write-InformFlag banner): a brand-new net must not move
# the audited baseline on day one, in EITHER direction. No teeth are lost by this --
# 2.1.8(b) still FAIL-LOUDs at runtime, independently of this script. Fold + severity
# raise is a post-triage, owner-gated call.
#
# git-based => honours -RepoRoot, so fault-injection on a temp tree isolates completely
# (mirror of H25-closeout). Every probe is exit-code checked and never throws: a RepoRoot
# that is not a work-tree, or a file that was never ticked, SKIPs WITH A REASON and the
# section summary says MEASURED NOTHING -- a 0 printed after a skip is not a green.
#
# READ-ENCODING (bug E-010/S130): the JSON is read via ReadAllBytes + UTF8.GetString and
# NOT Get-Content without -Encoding. A BOM-less UTF-8 file decoded through the ANSI
# codepage yields a DIFFERENT document than the one on disk, and a classifier that reads
# a different document is worse than no classifier.
# ---------------------------------------------------------------------------
Write-Section 'H24-5 - counter-ancestor-classify (INFORM-only)'

$h245Flags = 0
$h245Measured = $false
$counterP = Join-Path $RepoRoot '.claude\governance\.session-counter.json'
if (-not (Test-Path $counterP)) {
    Write-Host '  [skip] no .claude/governance/.session-counter.json - no tick state to classify' -ForegroundColor DarkGray
}
elseif (-not (Test-Path (Join-Path $RepoRoot '.git'))) {
    Write-Host '  [skip] RepoRoot is not a git work-tree - cannot classify head reachability' -ForegroundColor DarkGray
}
else {
    # BOM-STRIP (measured S142, NOT theoretical): the file on disk starts EF BB BF -- it is
    # written by PS 5.1 ConvertTo-Json + Out-File -Encoding utf8, which always emits a BOM.
    # [Text.Encoding]::UTF8.GetString does NOT consume that BOM (unlike Get-Content or a
    # StreamReader with detectEncodingFromByteOrderMarks), so the string begins with a
    # literal U+FEFF and ConvertFrom-Json dies with "Invalid JSON primitive: .".
    # First run of this detector on the REAL repo raised counter-unparseable on a file that
    # parses fine -- i.e. the encoding-safe read prescribed to dodge bug E-010 opened a
    # SECOND failure mode of its own. Both must be handled: read bytes as UTF-8 (never let
    # the ANSI codepage decode it) AND drop a leading BOM. Written as a code point so this
    # .ps1 stays ASCII-only (gotcha #30).
    $ctr = $null
    try {
        $ctrJson = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($counterP))
        $ctrJson = $ctrJson.TrimStart([char]0xFEFF)
        $ctr = ($ctrJson | ConvertFrom-Json)
    }
    catch { $ctr = $null }

    if ($null -eq $ctr) {
        Write-InformFlag (Rel $counterP) `
            'counter-unparseable: .session-counter.json is not valid JSON - tick state UNREADABLE, classifying NOTHING' `
            'fix the JSON syntax (the tick contract cannot be replayed against an unparseable file)'
        $h245Flags++
        $h245Measured = $true
    }
    else {
        # ---- axis 1: reachability of last_ticked_head ----
        $head = [string]$ctr.last_ticked_head
        if ([string]::IsNullOrWhiteSpace($head)) {
            Write-Host '  [skip-axis1] last_ticked_head is null - never ticked (seed-honesty state), nothing to classify' -ForegroundColor DarkGray
        }
        else {
            $h245Measured = $true
            $shortHead = $head.Substring(0, [Math]::Min(8, $head.Length))
            $reachable = $false
            try {
                $null = & git -C $RepoRoot merge-base --is-ancestor $head HEAD 2>$null
                $reachable = ($LASTEXITCODE -eq 0)
            }
            catch { $reachable = $false }

            if ($reachable) {
                Write-Host ("  [OK] OK-reachable: last_ticked_head {0} (session {1}) is an ancestor of HEAD" -f `
                        $shortHead, $ctr.last_ticked_session) -ForegroundColor Green
            }
            else {
                $objType = ''
                $catOk = $false
                try {
                    $objType = & git -C $RepoRoot cat-file -t $head 2>$null
                    $catOk = ($LASTEXITCODE -eq 0)
                }
                catch { $catOk = $false }

                if ($catOk -and (("$objType").Trim() -eq 'commit')) {
                    Write-Host ("  [trace] SQUASH-BENIGN: last_ticked_head {0} (session {1}) is NOT reachable from HEAD but the object EXISTS (cat-file -t = commit) - expected closeout-squash drift, 0 flag (contract v2)" -f `
                            $shortHead, $ctr.last_ticked_session) -ForegroundColor DarkGray
                }
                else {
                    Write-InformFlag (Rel $counterP) `
                        ("object-missing: last_ticked_head {0} (session {1}) is neither reachable from HEAD nor present as an object (cat-file -t failed) - NOT the benign squash class" -f `
                            $head, $ctr.last_ticked_session) `
                        'confirm history was not rewritten; re-tick from a real HEAD or record why the object vanished (contract: object-missing => FAIL-LOUD at runtime)'
                    $h245Flags++
                }
            }
        }

        # ---- axis 2 [M1]: counter-regress vs the already-audited floor (DEFAULT-ON) ----
        $curCounter = $null
        if ($null -ne $ctr.counter) { $curCounter = [int]$ctr.counter }
        $audit = $ctr.last_audit
        if ($null -eq $curCounter) {
            Write-InformFlag (Rel $counterP) `
                'counter-key-missing: no "counter" key - cadence state unreadable, regress-check measuring NOTHING' `
                'restore the counter key (H24 tick state lives in this file only)'
            $h245Flags++
            $h245Measured = $true
        }
        elseif ($null -eq $audit) {
            Write-Host '  [note] no last_audit block - regress-check has no audited floor to compare against' -ForegroundColor DarkGray
        }
        else {
            foreach ($floorKey in @('light_at_counter', 'deep_at_counter')) {
                $floorVal = $audit.$floorKey
                if ($null -eq $floorVal) { continue }
                $h245Measured = $true
                $floorN = [int]$floorVal
                if ($curCounter -lt $floorN) {
                    Write-InformFlag (Rel $counterP) `
                        ("counter-regress: counter={0} sits BELOW last_audit.{1}={2} - an audit already ran at a HIGHER counter, so this file was rolled back or hand-edited" -f `
                            $curCounter, $floorKey, $floorN) `
                        'restore the counter to its true value; never silently reset (contract: regress => FAIL-LOUD at runtime, cadence gap must stay visible)'
                    $h245Flags++
                }
                else {
                    Write-Host ("  [ok] counter={0} >= last_audit.{1}={2}" -f $curCounter, $floorKey, $floorN) -ForegroundColor DarkGray
                }
            }
        }
    }
}
$h245Note = if ($h245Measured) { '' } else { ' -- MEASURED NOTHING (skipped above); a 0 after a skip is not a green' }
Write-Host ("  H24-5 counter-ancestor flags = {0} [INFORM-only, LOW, NOT folded into TOTAL baseline]{1}" -f $h245Flags, $h245Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C9 - hmw WIDTH mirror-drift (INFORM-only, NEW @S149; count SEPARATE, NOT in TOTAL)
# ---------------------------------------------------------------------------
# WHY: `hmw_width.cap` in memory-budget.json is CANONICAL, but hmw.js must hardcode the
# same number as a MIRROR because the workflow sandbox has no filesystem (hmw.js:5).
# Two copies of one number = exactly the drift class B1 exists to prevent. Measured @S149:
# `grep -ril 'width' scripts/` = 0 hit => nothing was checking this pair at all.
# INFORM-only per repo convention: a NEW net reads separately first; folding into TOTAL
# (and raising severity) is post-triage and owner-gated, never the net author's call.
Write-Section 'C9 - hmw WIDTH mirror-drift (INFORM-only)'
$c9Flags = 0
$c9Measured = $false
$hmwP = Join-Path $RepoRoot '.claude\workflows\hmw.js'
$budP = Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json'
if (-not (Test-Path $hmwP)) {
    Write-Host '  [skip] hmw.js not found' -ForegroundColor DarkGray
}
elseif (-not (Test-Path $budP)) {
    Write-Host '  [skip] memory-budget.json not found' -ForegroundColor DarkGray
}
else {
    $hmwTxt = Get-Content -LiteralPath $hmwP -Raw -Encoding UTF8
    $mirrorM = [regex]::Match($hmwTxt, 'const\s+WIDTH_CAP\s*=\s*(\d+)')
    $budObj = $null
    try { $budObj = (Get-Content -LiteralPath $budP -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { $budObj = $null }
    $canon = if ($null -ne $budObj -and $null -ne $budObj.hmw_width) { $budObj.hmw_width.cap } else { $null }

    if (-not $mirrorM.Success) {
        # FAIL-LOUD on absence: a missing mirror is NOT a green. Same discipline as H24-2
        # (missing key => fail loud, never assume a default).
        Write-InformFlag (Rel $hmwP) `
            'hmw WIDTH mirror MISSING: no `const WIDTH_CAP = <n>` found - the cap may have been removed or renamed, which silently drops the runaway guard' `
            'restore the mirror, or if the guard was intentionally retired, remove hmw_width from memory-budget.json in the SAME change'
        $c9Flags++; $c9Measured = $true
    }
    elseif ($null -eq $canon) {
        Write-InformFlag (Rel $budP) `
            ('hmw WIDTH canonical MISSING: hmw.js hardcodes {0} but memory-budget.json has no hmw_width.cap - the number has no owner-visible home' -f $mirrorM.Groups[1].Value) `
            'add hmw_width {cap, mechanism, ratified, _ratified_by} to memory-budget.json (precedent: distill_trigger O-1/O-6)'
        $c9Flags++; $c9Measured = $true
    }
    else {
        $c9Measured = $true
        $mirrorN = [int]$mirrorM.Groups[1].Value
        $canonN = [int]$canon
        if ($mirrorN -ne $canonN) {
            Write-InformFlag (Rel $hmwP) `
                ('hmw WIDTH DRIFT: hmw.js mirror={0} but memory-budget.json hmw_width.cap={1} - the running guard does not match the ratified number' -f $mirrorN, $canonN) `
                'sync hmw.js WIDTH_CAP to the canonical value, then re-run the 2-way fault-inject on the new number (cap+1 => 4 loud lines; cap => silent)'
            $c9Flags++
        }
        else {
            Write-Host ("  [ok] WIDTH mirror {0} == canonical hmw_width.cap {1}" -f $mirrorN, $canonN) -ForegroundColor DarkGray
        }
    }
}
$c9Note = if ($c9Measured) { '' } else { ' -- MEASURED NOTHING (skipped above); a 0 after a skip is not a green' }
Write-Host ("  C9 width-drift flags = {0} [INFORM-only, LOW, NOT folded into TOTAL baseline]{1}" -f $c9Flags, $c9Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C10 - logic-session folder missing / unopened (INFORM-only, NEW @S149)
# ---------------------------------------------------------------------------
# WHY THIS EXISTS - first-person evidence, not a hypothetical:
#   /session-start BUOC 0.8 requires opening `.claude/sessions/session-<N>/` at the START of a
#   session. At S149 the lead ECHOED that very rule verbatim (BUOC 0 prints the whole command
#   body) and then skipped it anyway - the folder was created ~1.5h late, after 3 large
#   workflows, and only because the OWNER noticed. Owner's words: "day la ly do ma nghi thuc
#   nay bat buoc". A rule that lives only in lead memory is not a rule; it is a hope.
#   Same class as the `trio: skipped` WAL trace (ghost-wire: mandated, never once written).
# WHAT IT CHECKS: every session-<N> folder must (a) match ^session-\d+$, (b) carry its
#   _context-s-<N>.md, and (c) the HIGHEST N must be OPEN (no _end / closed.md) - because a
#   running session must have an open logic-session to write into. All-closed => nobody opened
#   one for the session that is running right now.
# [!] KHAI THAT - what this CANNOT do: it cannot prove the folder was opened EARLY (on time).
#   A late-but-present folder reads identical to a punctual one. It catches ABSENCE, not
#   LATENESS. Closing that gap needs a timestamp compare against session start, which the
#   detector has no reliable source for. Do not read a green C10 as "ritual ran on time".
# INFORM-only per repo convention (new net reads separately; folding into TOTAL is owner-gated).
Write-Section 'C10 - logic-session folder (INFORM-only)'
$c10Flags = 0
$c10Measured = $false
$sessRoot = Join-Path $RepoRoot '.claude\sessions'
if (-not (Test-Path $sessRoot)) {
    Write-Host '  [skip] .claude/sessions not found' -ForegroundColor DarkGray
}
else {
    $c10Measured = $true
    $sessDirs = @(Get-ChildItem -LiteralPath $sessRoot -Directory -ErrorAction SilentlyContinue)
    if ($sessDirs.Count -eq 0) {
        Write-InformFlag (Rel $sessRoot) `
            'no logic-session folder at all: /session-start BUOC 0.8 opens session-<N>/ but none exists' `
            'create .claude/sessions/session-1/ with _context-s-1.md (STOCK-map + FLOW-START)'
        $c10Flags++
    }
    else {
        $maxN = -1
        foreach ($d in $sessDirs) {
            if ($d.Name -notmatch '^session-(\d+)$') {
                Write-InformFlag (Rel $d.FullName) `
                    ("logic-session folder name '{0}' breaks the machine-scannable form ^session-\d+$" -f $d.Name) `
                    'rename to session-<N> (no L<mm>, no descriptive suffix - tooling scans this pattern)'
                $c10Flags++
                continue
            }
            $n = [int]$Matches[1]
            if ($n -gt $maxN) { $maxN = $n }
            $ctx = Join-Path $d.FullName ("_context-s-{0}.md" -f $n)
            $legacy = @(Get-ChildItem -LiteralPath $d.FullName -Filter 'pause-*.md' -ErrorAction SilentlyContinue).Count
            if (-not (Test-Path $ctx) -and $legacy -eq 0) {
                Write-InformFlag (Rel $d.FullName) `
                    ("session-{0} has neither _context-s-{0}.md (hub form) nor legacy pause-*.md - the folder carries no narrative at all" -f $n) `
                    'write _context-s-<N>.md with (a) STOCK-map pointers and (b) FLOW-START'
                $c10Flags++
            }
        }
        if ($maxN -ge 0) {
            $topDir = Join-Path $sessRoot ("session-{0}" -f $maxN)
            $isClosed = (Test-Path (Join-Path $topDir '_end')) -or (Test-Path (Join-Path $topDir 'closed.md'))
            if ($isClosed) {
                Write-InformFlag (Rel $sessRoot) `
                    ("highest logic-session (session-{0}) is CLOSED and no newer one was opened - if a session is running now, BUOC 0.8 was skipped" -f $maxN) `
                    ("open session-{0} with _context-s-{0}.md; only /session-start may mint a new <N>" -f ($maxN + 1))
                $c10Flags++
            }
            else {
                Write-Host ("  [ok] session-{0} is OPEN (no _end/closed.md)" -f $maxN) -ForegroundColor DarkGray
            }
        }
    }
}
$c10Note = if ($c10Measured) { '' } else { ' -- MEASURED NOTHING (skipped above); a 0 after a skip is not a green' }
Write-Host ("  C10 logic-session flags = {0} [INFORM-only, LOW, NOT folded into TOTAL baseline]{1}" -f $c10Flags, $c10Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C11 - H24 FLAG-khuon + diary-delta (INFORM-only, NEW @S149; count SEPARATE, NOT in TOTAL)
# ---------------------------------------------------------------------------
# TWO checks over run-folder H24 artifacts, both born from a MEASURED S148 loss:
#  (a) FLAG-khuon: every H24 auditor sub-file (name carries lead-stale|lead-gap|ring2-audit)
#      that RAISES an enumerated FLAG-<n> must tag it as
#          "## FLAG-<n> <em-dash> `<class>` <em-dash> <SEV>"   (class family view-* / gap-*)
#      A file that carries a FLAG-<n> but has NO conforming tag line => the by-class tally
#      that reads these files CANNOT count it. That is the exact S148 defect: the vong
#      dropped the khuon and 2 of 9 FLAGs fell out of the count (spec M4). INFORM, because
#      the fix is procedural (re-tag), not a build break.
#      -> WHY the enumerated form "FLAG-<n>" and NOT the bare word "FLAG": a CLEAN auditor
#         legitimately writes "0 FLAG" / "no FLAG this run" - which contains the word FLAG
#         but raised nothing to tag. Triggering on the bare word would FLAG a clean run =
#         false positive on the very role (ring2-audit) that most often reports 0. The
#         enumerated form appears ONLY when a flag was actually raised. This REFINES the
#         spec's loose "chu FLAG" to the non-false-positive signal (anti-Goodhart: a 0 must
#         be a real 0, never a net that cannot tell clean from broken).
#      -> LIMIT (khai that): file-level. One conforming tag line silences the whole file, so
#         a file that tags 1 of 2 flags is NOT caught here (partial-tagging is a harder check
#         the tally itself must do). C11 catches the TOTAL-omission case S148 actually hit
#         (khuon dropped entirely). A QUOTED example tag also counts as conforming => a doc
#         quoting the format reads silent; silence is the safe direction (no false alarm).
#  (b) diary-delta (M9): a run-folder carrying sub-<role>-*.md for an INFORM-only role (list
#      read LIVE from .claude/agents/*.md frontmatter carrying 'INFORM-only') means that role
#      RAN and produced an artifact. If the role's diary (.claude/agent-memory/<role>/) has
#      NO git commit at/after that artifact's mtime, the role ran but nobody seeded its memory
#      => GAP-3 (spec M9: recurred x4 S124->S149). git is the delta source the spec names; it
#      is a LOCAL VCS query, not a model/API, so it is within the NO-API mandate. A missing OR
#      older commit => flag. git absent => say so (do not read "cannot measure" as clean).
# *** FROZEN-HISTORY GATE (critical): only run-folders with LastWriteTime >= $C11_LAND_DATE scanned.
#    Older folders predate this khuon AND the separated lead-stale/lead-gap roles; re-flagging
#    them is noise about a closed period (spec section 1: "S148 ve truoc = frozen, da biet").
#    Do NOT "freshen" the constant below - it is the day C11 landed, by design.
# INFORM-only per repo convention (new net reads separately; fold into TOTAL is owner-gated).
Write-Section 'C11 - H24 FLAG-khuon + diary-delta (INFORM-only)'
$c11Flags = 0
$c11Measured = $false

# LAND DATE - the day this detector shipped (S149, spec M4). Run-folders touched BEFORE this
# are frozen history. GRANULARITY note (measured @S149): the S148 force-fire folder shares
# this calendar day but wrote a COMBINED sub-h24-audit-*.md that matches NONE of the
# lead-stale|lead-gap|ring2-audit name patterns, so it is scanned-but-inert. Verified on disk
# @S149: `find runs -name '*lead-stale*' -o -name '*lead-gap*' -o -name '*ring2-audit*'` = 0
# hits across ALL history => the FIRST files to match will be produced by roles running UNDER
# this khuon, i.e. after this lands. A date-level gate therefore suffices (no matching trigger
# file exists in any frozen folder, so nothing frozen can fire).
$C11_LAND_DATE = [datetime]'2026-07-24'

$runsRoot = Join-Path $RepoRoot '.claude\workflows\runs'
if (-not (Test-Path $runsRoot)) {
    Write-Host '  [skip] .claude/workflows/runs not found' -ForegroundColor DarkGray
}
else {
    # INFORM-only role list, read LIVE from agent frontmatter so a NEW monitor auto-joins
    # (spec M9: "doc song"). Scoped to the frontmatter block (where description lives) so a
    # body mention of 'INFORM-only' in a non-monitor agent does not falsely enroll it.
    $informRoles = New-Object System.Collections.Generic.List[string]
    $agentsDir = Join-Path $RepoRoot '.claude\agents'
    if (Test-Path $agentsDir) {
        foreach ($af in (Get-ChildItem -LiteralPath $agentsDir -Filter *.md -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'README.md' })) {
            $al = @(Get-Content -LiteralPath $af.FullName -Encoding UTF8 -ErrorAction SilentlyContinue)
            if ($al.Count -eq 0 -or $al[0].Trim() -ne '---') { continue }
            $fmEnd = -1
            for ($j = 1; $j -lt $al.Count; $j++) { if ($al[$j].Trim() -eq '---') { $fmEnd = $j; break } }
            if ($fmEnd -le 1) { continue }
            $fmLines = $al[1..($fmEnd - 1)]
            if (($fmLines -join "`n") -notmatch 'INFORM-only') { continue }
            $rname = $null
            foreach ($fl in $fmLines) {
                $nm = [regex]::Match($fl, '^\s*name:\s*(.+?)\s*$')
                if ($nm.Success) { $rname = $nm.Groups[1].Value.Trim(); break }
            }
            if (-not $rname) { $rname = $af.BaseName }
            if (-not $informRoles.Contains($rname)) { $informRoles.Add($rname) | Out-Null }
        }
    }

    # git availability probe (part b needs it). Absent git => part (b) cannot measure delta;
    # we say so per file rather than silently reading a role as clean.
    $gitOk = $false
    try { $null = (& git --version 2>$null); $gitOk = ($LASTEXITCODE -eq 0) } catch { $gitOk = $false }

    # class-tag khuon (M4). em-dash from code point ($EM_DASH, :112 - ASCII-source rule,
    # gotcha #30); backtick optional (roles sometimes drop the code-span); class view-/gap-.
    $flagEnumRx = 'FLAG-\d+'
    $flagTagRx  = '## FLAG-\d+ ' + $EM_DASH + ' `?(?:view-|gap-)[a-z-]+`? ' + $EM_DASH + ' (?:LOW|MED|HIGH)'
    $roleNameRx = '(?i)(lead-stale|lead-gap|ring2-audit)'

    $freshFolders = @(Get-ChildItem -LiteralPath $runsRoot -Directory -ErrorAction SilentlyContinue |
            Where-Object { $_.LastWriteTime -ge $C11_LAND_DATE })

    foreach ($rf in $freshFolders) {
        # @S159: mirror of the C11(b) filter fix above - '<role>-return.md' counts as a per-role
        # artifact too, otherwise the trio lanes are invisible here as well (ctx-audit FLAG-6).
        $subMd = @(Get-ChildItem -LiteralPath $rf.FullName -File -ErrorAction SilentlyContinue |
                   Where-Object { $_.Name -match '^(sub-.+|.+-return)\.md$' })
        foreach ($sm in $subMd) {

            # ---- (a) FLAG-khuon: H24 auditor sub-file must class-tag every raised flag ----
            if ($sm.Name -match $roleNameRx) {
                $c11Measured = $true
                $smLines = @(Get-Content -LiteralPath $sm.FullName -Encoding UTF8 -ErrorAction SilentlyContinue)
                $hasEnum = $false; $tagged = $false; $firstEnumLine = 0
                for ($li = 0; $li -lt $smLines.Count; $li++) {
                    if ($smLines[$li] -cmatch $flagEnumRx) { $hasEnum = $true; if ($firstEnumLine -eq 0) { $firstEnumLine = $li + 1 } }
                    if ($smLines[$li] -match $flagTagRx) { $tagged = $true }
                }
                if ($hasEnum -and -not $tagged) {
                    if ($firstEnumLine -eq 0) { $firstEnumLine = 1 }
                    Write-InformFlag ("{0}:{1}" -f (Rel $sm.FullName), $firstEnumLine) `
                        'H24 FLAG-khuon: sub-file raises FLAG-<n> but has NO conforming class-tag line "## FLAG-<n> [em-dash] `<view-/gap-...>` [em-dash] SEV" - the by-class tally cannot count an untagged flag (S148 lost 2/9 flags exactly this way)' `
                        'tag EACH raised flag on its own line "## FLAG-<n> [em-dash=U+2014] `<class>` [em-dash] SEV" (class in view-*/gap-*); one conforming line un-flags the file'
                    $c11Flags++
                }
            }

            # ---- (b) diary-delta (M9): INFORM-only role artifact with no fresher diary commit ----
            foreach ($role in $informRoles) {
                if ($sm.Name -match ('^sub-' + [regex]::Escape($role) + '[-.]')) {
                    if (-not $gitOk) {
                        Write-Host ("  [note] git unavailable - diary-delta for role '{0}' NOT measured (not read as clean)" -f $role) -ForegroundColor DarkGray
                        continue
                    }
                    $c11Measured = $true
                    $diaryRel = ".claude/agent-memory/$role/"
                    $gitOut = $null; $code = 1
                    try { $gitOut = & git -C $RepoRoot log -1 --format=%cI -- $diaryRel 2>$null; $code = $LASTEXITCODE } catch { $gitOut = $null; $code = 1 }
                    $diaryTime = $null
                    if ($code -eq 0 -and $gitOut) {
                        $parsed = [datetime]::MinValue
                        if ([datetime]::TryParse(([string]$gitOut).Trim(), [ref]$parsed)) { $diaryTime = $parsed }
                    }
                    if (($null -eq $diaryTime) -or ($diaryTime -lt $sm.LastWriteTime)) {
                        $seen = if ($null -eq $diaryTime) { 'no diary commit found' } else { ('diary last commit ' + $diaryTime.ToString('yyyy-MM-ddTHH:mm')) }
                        Write-InformFlag (Rel $sm.FullName) `
                            ("diary-0-delta (GAP-3): role '{0}' produced an artifact (mtime {1}) but its diary has no newer commit ({2}) - role ran, memory not seeded (spec M9)" -f $role, $sm.LastWriteTime.ToString('yyyy-MM-ddTHH:mm'), $seen) `
                            ("lead APPEND agent-memory/{0}/ on-behalf IN THE SAME session (khuon B3) and commit, so next session does not enter blind" -f $role)
                        $c11Flags++
                    }
                }
            }
        }
    }
}
$c11Note = if ($c11Measured) { '' } else { ' -- MEASURED NOTHING (no fresh H24 sub-file or INFORM-role artifact in the >= land-date window); a 0 after a skip is not a green' }
Write-Host ("  C11 FLAG-khuon + diary-delta flags = {0} [INFORM-only, LOW, NOT folded into TOTAL baseline]{1}" -f $c11Flags, $c11Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C12 - raw-engine-bypass (INFORM-only, NEW @S149; count SEPARATE, NOT in TOTAL)
# ---------------------------------------------------------------------------
# FLOOR SOURCE - hub answer, quoted from
#   broadcasts/inbox/ai_infra/2026-07-03-ai_infra-to-se-batch13-ack-stamp-and-answers.md:27-29
#   (section title :27 = "Tra loi cau hoi 1 - detector 'workflow chay ngoai engine chuan' co bat
#   buoc khong?"; answer :29, verbatim, diacritics stripped for the ASCII-source rule):
#     "Co, o muc san-chuc-nang; con hinh thuc thi SE tu quyet. San = du an phai CO mot cach phat
#      hien duoc duong chay workflow khong di qua engine chuan cua minh (vi duong do bo qua toan
#      bo enforce ve harvest ky uc + vai + checkpoint - day la lop loi tung gay tut chat luong ky
#      uc toan fleet, da RCA). Hinh thuc dat san thi tu chon: grep transcript, quet ledger-vs-run-
#      folder, hay 'untracked-run detector nhe' nhu SE de xuat - deu dat, mien chay deu moi cuoi
#      phien."
#   => This CLOSES the live residue of broadcast 2026-06-18-Governance-h10-flat-detector-refine
#      khoan (b), which SE TAILORED-OUT at S72 (docs/governance/adap-reports/
#      2026-06-18-Governance-harness-10-flat-refine-checklist-v2.md:46). The tailored-out reason
#      ("SE runs through the Anthropic Workflow tool, no CLI-launcher bypass-surface") was later
#      FALSIFIED on disk: S119 measured THREE spawn paths - hmw.js (gated), raw Workflow, raw
#      Agent - two of them ungated (docs/changelog/sessions/
#      2026-07-15-S119-adap-6-broadcast-spec-v3.md:69 and :128, "ke CON-LAI").
# FORM CHOSEN = session-log sweep (item 1/3 of the hub menu, the cheapest). It ships inside this
#   net, which is invoked at session-end => satisfies "chay deu moi cuoi phien".
# WHAT IT CHECKS: a session log that MENTIONS a spawn (line carrying 'raw Workflow' / 'raw Agent'
#   / 'raw-Agent' / a 'wf_' run id) while the WHOLE FILE carries no 'run=' line. 'run=' is the
#   registration token of the wf: line - form measured on disk: "wf: <label> run=<run-id>
#   runId=wf_<hex>" (docs/changelog/sessions/2026-07-16-2250-S129-adap-errata-eol.md:8) - and it
#   is what binds a spawn to a tracked run-folder. Mention-without-registration = the signature
#   of a workflow that ran outside the standard engine.
# USE/MENTION discriminator (citation-trap class): a doc that DESCRIBES this detector inevitably
#   contains its own trigger tokens. Two enclosure guards, both on the silence side:
#     (a) lines inside ``` fences are skipped;
#     (b) a line that also names 'C12' or 'governance-detectors' is skipped (self-reference).
#   Stated cost: a REAL bypass reported on a line that happens to name C12 reads silent. For a
#   day-one INFORM net, silence is the safe direction (a false alarm kills the net faster).
# [!] KHAI THAT - what this CANNOT do:
#   (1) FILE-level guard: ONE 'run=' anywhere silences the whole file. A session that registered
#       run A and then spawned B raw is NOT caught. Per-spawn correlation is not derivable from
#       the session log's prose.
#   (2) It reads the session's OWN account. A bypass nobody wrote down stays invisible - this is
#       a self-report net, not a transcript net (the transcript form on the hub menu covers that,
#       at much higher cost). Do NOT read a green C12 as "no bypass happened".
#   (3) It does not prove the run-folder exists; it only reads the registration token.
# *** FROZEN-HISTORY GATE (critical): only session logs with LastWriteTime >= $C12_LAND_DATE.
#   MEASURED reason (dry run 2026-07-25 over all 148 session logs): 36 carry 'wf_' but only 9
#   carry 'run=' => 29 files would flag on day one. Those predate both the wf:-registration habit
#   and this net; re-flagging a closed period is noise, and 29 LOW lines on day one is exactly how
#   a net gets ignored. Same discipline as C11. Do NOT "freshen" the constant below - it is the
#   day C12 landed, by design.
# INFORM-only per repo convention (a new net reads separately first; folding into TOTAL and
#   raising severity is post-triage and owner-gated, never the net author's call).
Write-Section 'C12 - raw-engine-bypass (INFORM-only)'
$c12Flags = 0
$c12Measured = $false

# LAND DATE - the day this detector shipped (S149, C-b). Session logs touched BEFORE = frozen.
$C12_LAND_DATE = [datetime]'2026-07-25'

$sessLogDir = Join-Path $RepoRoot 'docs\changelog\sessions'
if (-not (Test-Path $sessLogDir)) {
    Write-Host '  [skip] docs/changelog/sessions not found' -ForegroundColor DarkGray
}
else {
    # Spawn-mention tokens. 'raw[- ]Workflow|Agent' covers both spellings seen on disk
    # (S119:69 writes "raw Workflow"/"raw Agent"; S131:17 writes "raw-Agent").
    $bypassRx  = 'raw[-\s]?(?:Workflow|Agent)|wf_'
    $runRegRx  = 'run='
    $mentionRx = 'C12|governance-detectors'

    $freshLogs = @(Get-ChildItem -LiteralPath $sessLogDir -Filter *.md -File -ErrorAction SilentlyContinue |
            Where-Object { $_.LastWriteTime -ge $C12_LAND_DATE })

    if ($freshLogs.Count -eq 0) {
        Write-Host ('  [note] no session log with mtime >= {0} - window EMPTY, nothing measured (not a green)' -f `
                $C12_LAND_DATE.ToString('yyyy-MM-dd')) -ForegroundColor DarkGray
    }

    foreach ($sl in $freshLogs) {
        $c12Measured = $true
        $slLines = @(Get-Content -LiteralPath $sl.FullName -Encoding UTF8 -ErrorAction SilentlyContinue)

        # File-level registration guard: one wf: run= line anywhere means this session DID
        # register its run-folder(s) -> whole file silent (see LIMIT (1) above).
        $registered = $false
        foreach ($ln in $slLines) { if ($ln -match $runRegRx) { $registered = $true; break } }
        if ($registered) {
            Write-Host ("  [ok] {0} carries a run= registration line" -f $sl.Name) -ForegroundColor DarkGray
            continue
        }

        $inFence = $false
        $hitLine = 0
        $hitTok = ''
        for ($li = 0; $li -lt $slLines.Count; $li++) {
            $ln = $slLines[$li]
            if ($ln -match '^\s*```') { $inFence = -not $inFence; continue }
            if ($inFence) { continue }
            if ($ln -match $mentionRx) { continue }
            $bm = [regex]::Match($ln, $bypassRx, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
            if ($bm.Success) { $hitLine = $li + 1; $hitTok = $bm.Value; break }
        }

        if ($hitLine -gt 0) {
            Write-InformFlag ("{0}:{1}" -f (Rel $sl.FullName), $hitLine) `
                ("raw-engine-bypass: session log mentions a spawn ('{0}') but the WHOLE file carries no 'run=' registration line - a workflow that never registered a run-folder skips the harvest/role/checkpoint enforcement (hub floor 2026-07-03 answer, section 3)" -f $hitTok) `
                "add the wf: line 'wf: <label> run=<run-id> runId=wf_<id>' to this session log, or re-run through .claude/workflows/hmw.js so a tracked run-folder exists; a DELIBERATE raw-Agent monitor is fine but must be declared in a session that also carries run="
            $c12Flags++
        }
        else {
            Write-Host ("  [ok] {0} - no unregistered spawn mention" -f $sl.Name) -ForegroundColor DarkGray
        }
    }
}
$c12Note = if ($c12Measured) { '' } else { ' -- MEASURED NOTHING (no session log at/after the land date); a 0 outside the window is not a green' }
Write-Host ("  C12 raw-engine-bypass flags = {0} [INFORM-only, LOW, NOT folded into TOTAL baseline]{1}" -f $c12Flags, $c12Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C13 - carve-out liveness (NEW @S185; FOLDED vao TOTAL @S185 - YC-020(1) owner 'OK, 1 phien'; nen INFORM = bookend S185)
# ---------------------------------------------------------------------------
# NAMING: this is the script's OWN C-numbering (C1..C12 already exist above). It is a
# DIFFERENT namespace from the S185 run-checklist codes; this detector implements
# checklist item C-8. Do not read "C13" as "checklist C-13".
#
# WHY: docs/governance/error-ledger.md "Carve-out o3 - dieu-kien SONG" pins the
# force-fire carve-out at rank `PASS-voi-carve-out`, VALID **only while BOTH grids are
# alive at the same time** (error-ledger:52-54, verbatim targets):
#   (a) the read-only debt line still PRINTS at every stop point - scripts/nhip-no-probe.ps1
#   (b) the "closeout-missing-ritual" probe is still WIRED - section H25-closeout-ritual
#       (GAP-2) in THIS script
# One grid dead => the carve-out loses its self-transparency layer => RE-EVAL the rank,
# and fall back to unconditional running until the grid is alive again (error-ledger:58).
#
# THE GAP THIS CLOSES - self-declared, not hypothetical. The SE adap-report for the
# 2026-07-17 hub reply says, in its own honest-caveat (section 5):
#   "Carve-out dang duoc canh bang NGUOI, khong bang MAY ... khong co detector nao canh
#    chinh dieu-kien do. Neu mot luoi chet lang, nac PASS-voi-carve-out se TIEP TUC duoc
#    khai ma khong ai biet"
# and section 4 files the liveness detector as "follow-up, chua build". This section is that
# follow-up. Until now the condition holds by DISCIPLINE, which is exactly the
# `cam-bang-tri-nho` class the ledger forbids.
#
# HOW EACH GRID IS MEASURED (existence is NOT liveness):
#   (a) is measured in TWO parts, because a guard can fail in two independent ways:
#       (a1) EMITS - the probe is RUN and must print a line starting `NHIP-NO:`.
#            Existence alone proves nothing: nhip-no-probe.ps1 exits 0 ALWAYS and
#            degrades to `probe-loi (khong chan)` on any exception, i.e. its failure
#            mode is precisely "alive-looking and silent". Only running it separates
#            the two. Cost measured before wiring: ~512 ms, and this script is an
#            audit tool, not a stop point (the stop-point cheapness predicate C7 is
#            about /pause and /tiep, not about the detector suite).
#       (a2) IS CALLED - at least one stop-point command file must carry a real
#            INVOCATION. use vs mention matters here as a measured fact, not a theory:
#            a prior count of "6 call sites" included .claude/commands/check-email.md,
#            where both hits are the script's NAME in prose and neither is a call; the
#            true set is 5. The predicate therefore requires `-File <...>nhip-no-probe.ps1`
#            on the line, which prose mentions do not carry. A machine that emits
#            perfectly but that nothing calls is dead in the only sense that matters.
#   (b) is measured by whether the H25 section SCORED anything this run
#       ($script:H25Scored), not by whether its code is present. A section that runs and
#       takes the [skip] branch prints reassuring output while measuring nothing.
#
# SEVERITY: INFORM. A dead grid is a governance fact of some weight, but folding a
# brand-new net into the audited TOTAL (and raising its severity) is a post-triage,
# owner-gated decision per the sink contract at the top of this file - never the net
# author's call. The line shape is identical to Write-Flag, so `comm before/after`
# isolates it and a fault-inject greps it exactly like any other flag.
Write-Section 'C13 - carve-out liveness (TOTAL @S185)'
$c13Flags = 0
$c13Measured = $false
$c13LedgerPath = Join-Path $RepoRoot 'docs\governance\error-ledger.md'
$c13Resolve = 'restore the dead grid, OR re-eval the carve-out rank in docs/governance/error-ledger.md (Carve-out o3) and fall back to unconditional running until it is alive again'

if (-not (Test-Path $c13LedgerPath)) {
    Write-Host '  [skip] docs/governance/error-ledger.md not found - no carve-out rank is being claimed here, nothing to guard' -ForegroundColor DarkGray
}
else {
    $c13Measured = $true

    # ---- grid (a1): the probe RUNS and EMITS ----
    $probePath = Join-Path $RepoRoot 'scripts\nhip-no-probe.ps1'
    $aEmits = $false
    $aWhy = ''
    if (-not (Test-Path $probePath)) {
        $aWhy = 'scripts/nhip-no-probe.ps1 is GONE from disk'
    }
    else {
        $pOut = ''
        try { $pOut = (& powershell.exe -ExecutionPolicy Bypass -File $probePath -RepoRoot $RepoRoot 2>&1 | Out-String) }
        catch { $pOut = '' }
        if ($pOut -match '(?m)^NHIP-NO:') { $aEmits = $true }
        else {
            $first = (($pOut -split "`r?`n") | Where-Object { $_.Trim().Length -gt 0 } | Select-Object -First 1)
            if ($null -eq $first) { $first = '(no output at all)' }
            $aWhy = ("the probe ran but printed no 'NHIP-NO:' line (first line: {0})" -f $first.Trim())
        }
    }

    # ---- grid (a2): at least one stop point CALLS it ----
    $callRx = '-File\s+\S*nhip-no-probe\.ps1'
    $callSites = @()
    $cmdDir = Join-Path $RepoRoot '.claude\commands'
    if (Test-Path $cmdDir) {
        foreach ($cf in (Get-ChildItem -Path $cmdDir -Filter *.md -File -ErrorAction SilentlyContinue)) {
            $cl = Get-Content -Path $cf.FullName -Encoding UTF8 -ErrorAction SilentlyContinue
            for ($i = 0; $i -lt $cl.Count; $i++) {
                if ($cl[$i] -match $callRx) { $callSites += ("{0}:{1}" -f $cf.Name, ($i + 1)) }
            }
        }
    }

    # ---- verdicts ----
    if (-not $aEmits) {
        Write-Flag 'LOW' 'scripts/nhip-no-probe.ps1' `
            ("carve-out grid (a) DEAD - the debt line no longer prints: {0}. error-ledger 'Carve-out o3' requires BOTH grids alive; rank PASS-voi-carve-out is NOT valid while this is dead" -f $aWhy) `
            $c13Resolve
        $c13Flags++
    }
    elseif ($callSites.Count -eq 0) {
        Write-Flag 'LOW' 'scripts/nhip-no-probe.ps1' `
            ("carve-out grid (a) DEAD-BY-DISCONNECT - the probe emits, but NO stop-point command actually calls it (0 lines matching '-File ...nhip-no-probe.ps1' under .claude/commands). A debt line nobody triggers is not printed 'at every stop point'") `
            $c13Resolve
        $c13Flags++
    }
    else {
        Write-Host ("  [ok] grid (a) ALIVE: nhip-no-probe emits NHIP-NO: and is CALLED from {0} stop point(s) - {1}" -f `
                $callSites.Count, ($callSites -join ', ')) -ForegroundColor DarkGray
    }

    if ($script:H25Scored -le 0) {
        Write-Flag 'LOW' 'scripts/governance-detectors.ps1 (H25-closeout-ritual)' `
            'carve-out grid (b) DEAD - the closeout-ritual probe took its [skip] branch and scored 0 close-group this run, i.e. it is wired but measuring NOTHING. error-ledger Carve-out o3 requires BOTH grids alive' `
            $c13Resolve
        $c13Flags++
    }
    else {
        Write-Host ("  [ok] grid (b) ALIVE: H25-closeout-ritual scored {0} close-group(s) this run" -f $script:H25Scored) -ForegroundColor DarkGray
    }

    # ---- grid (c): trio-memory liveness (YC-020(4) @S185 - owner 'OK' mo C13 sang luoi trio S141) ----
    # Absolute-liveness only: FLAG when ZERO harness-*-return.md exists under runs/ (net dead-silent).
    # No new threshold invented here (age printed INFORM-style; a threshold = an owner number).
    $trioReturns = @(Get-ChildItem -Path (Join-Path $RepoRoot '.claude\workflows\runs') -Recurse -Filter 'harness-*-return.md' -File -ErrorAction SilentlyContinue)
    if ($trioReturns.Count -eq 0) {
        Write-Flag 'LOW' '.claude/workflows/runs/' `
            'carve-out grid (c) DEAD - trio-memory net has ZERO harness-*-return.md on disk across ALL runs: the auto-1-round-per-session loop (S141/S151) is leaving no scribe trace at all' `
            $c13Resolve
        $c13Flags++
    }
    else {
        $newestTrio = ($trioReturns | Sort-Object LastWriteTime -Descending | Select-Object -First 1)
        Write-Host ("  [ok] grid (c) ALIVE: {0} harness-*-return.md on disk; newest = {1} ({2:yyyy-MM-dd HH:mm})" -f $trioReturns.Count, (Rel $newestTrio.FullName), $newestTrio.LastWriteTime) -ForegroundColor DarkGray
    }
}
$c13Note = if ($c13Measured) { '' } else { ' -- MEASURED NOTHING (skipped above); a 0 after a skip is not a green' }
Write-Host ("  C13 carve-out-liveness flags = {0} [FOLDED vao TOTAL @S185 YC-020(1) - nen INFORM = bookend S185]{1}" -f $c13Flags, $c13Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C14 - stamp_verify mirror-drift (NEW @S185; FOLDED vao TOTAL @S185 - YC-020(1))
# ---------------------------------------------------------------------------
# NAMING: script-local C-numbering (see C13 note). This implements S185 checklist C-19.
#
# WHY: scripts/stamp_verify.py is a MIRROR, not a source. Its own header says so:
#   "mirror-of AI_INFRA/scripts/stamp_verify.py (ported S141 ...) - re-pull khi hub doi
#    section-N canon. KHONG sua logic local."
# The SE adap-report for 2026-07-17-Governance-chuan-hoa-stamp-decode says it plainly
# (section 5 honest-caveat): "hub doi section-N canon ma SE quen re-pull thi 2 ben lech IM LANG - hien
# KHONG CO MAY NAO canh drift giua 2 ban, chi co header-note nhac NGUOI." Measured @S185:
# a note is the only guard, and a note guards nothing once nobody re-reads it. Two copies
# of one canon = the drift class B1 exists to prevent (same shape as C9 above).
#
# WHAT IS COMPARED - and why not the raw bytes. Measured today: the two files differ by
# exactly ONE line, the local mirror-of note itself (`diff` = 1 added line, 2378 B vs
# 2247 B). A raw-hash watcher would therefore INFORM on EVERY run forever - a permanently
# red lamp is read as noise within a week and is just a dead grid pointing the other way.
# So the drift verdict is taken on the LOGIC hash: the declared local-only note line is
# excluded (it is the file's own statement that only the note may differ), CRLF is
# normalized to LF (a checkout EOL setting is not a canon change), and the RAW hashes are
# printed alongside anyway so nothing is hidden by the normalization.
#
# HUB PATH IS DERIVED, NEVER HARDCODED: repo-root -> up 2 -> AI_INFRA. An absolute path
# with a machine-specific prefix silently degrades to the else-branch on any other
# machine, and a silent degrade reads exactly like "clean" - the S122 W5 lesson already
# paid for once in this file. Hub unreachable => "CHUA DO DUOC", never [ok], never silence.
Write-Section 'C14 - stamp_verify mirror-drift (TOTAL @S185)'
$c14Flags = 0
$c14Measured = $false

function Get-MirrorHashes {
    param([string]$Path)
    $txt = [System.IO.File]::ReadAllText($Path)
    $raw = $txt -replace "`r`n", "`n"
    $logic = (($raw -split "`n") | Where-Object { $_ -notmatch '^\s*#.*mirror-of' }) -join "`n"
    $sha = [System.Security.Cryptography.SHA256]::Create()
    $enc = New-Object System.Text.UTF8Encoding($false)
    $h1 = ($sha.ComputeHash($enc.GetBytes($raw))   | ForEach-Object { $_.ToString('x2') }) -join ''
    $h2 = ($sha.ComputeHash($enc.GetBytes($logic)) | ForEach-Object { $_.ToString('x2') }) -join ''
    return [pscustomobject]@{ Raw = $h1; Logic = $h2; Lines = (($raw -split "`n").Count) }
}

$mirLocal = Join-Path $RepoRoot 'scripts\stamp_verify.py'
$hubRoot  = $null
$p1 = Split-Path $RepoRoot -Parent
if ($null -ne $p1) { $p2 = Split-Path $p1 -Parent; if ($null -ne $p2) { $hubRoot = Join-Path $p2 'AI_INFRA' } }
$mirHub = if ($null -eq $hubRoot) { $null } else { Join-Path $hubRoot 'scripts\stamp_verify.py' }

if (-not (Test-Path $mirLocal)) {
    Write-Flag 'LOW' 'scripts/stamp_verify.py' `
        'stamp mirror MISSING locally - the ported verifier is gone, so /check-email and /send-email fall back to the regex-pin path with nothing checking the canon' `
        're-pull scripts/stamp_verify.py from AI_INFRA, or declare the regex-pin path canonical and retire the mirror in the SAME change'
    $c14Flags++; $c14Measured = $true
}
elseif ($null -eq $mirHub -or -not (Test-Path $mirHub)) {
    $shown = if ($null -eq $mirHub) { '(hub root underivable)' } else { $mirHub }
    Write-Host ("  CHUA DO DUOC: hub copy not reachable at {0} - drift is UNMEASURED on this machine (this is NOT a green; the mirror may be stale and nothing here can tell)" -f $shown) -ForegroundColor Yellow
}
else {
    $c14Measured = $true
    $hl = Get-MirrorHashes $mirLocal
    $hh = Get-MirrorHashes $mirHub
    if ($hl.Logic -ne $hh.Logic) {
        Write-Flag 'LOW' 'scripts/stamp_verify.py' `
            ("stamp mirror DRIFT: local logic-sha {0} != hub logic-sha {1} (local {2} lines / hub {3} lines; raw-sha local {4} hub {5}) - the mirror no longer carries the hub canon, so a stamp verified here can pass while the hub would fail it" -f `
                $hl.Logic.Substring(0, 12), $hh.Logic.Substring(0, 12), $hl.Lines, $hh.Lines, $hl.Raw.Substring(0, 8), $hh.Raw.Substring(0, 8)) `
            'BLOCKING NOTHING (INFORM): re-pull the hub copy over scripts/stamp_verify.py keeping ONLY the mirror-of header note, then re-run this detector'
        $c14Flags++
    }
    else {
        $rawNote = if ($hl.Raw -eq $hh.Raw) { 'raw identical too' } else { ('raw differs only by the declared mirror-of note: local {0} / hub {1}' -f $hl.Raw.Substring(0, 8), $hh.Raw.Substring(0, 8)) }
        Write-Host ("  [ok] stamp mirror in sync: logic-sha {0} on both sides ({1})" -f $hl.Logic.Substring(0, 12), $rawNote) -ForegroundColor DarkGray
    }
}
$c14Note = if ($c14Measured) { '' } else { ' -- MEASURED NOTHING (hub unreachable); a 0 here is "chua do duoc", not a green' }
Write-Host ("  C14 stamp-mirror-drift flags = {0} [FOLDED vao TOTAL @S185 YC-020(1)]{1}" -f $c14Flags, $c14Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# C15 - V-5 encoding predicate (NEW @S185; FOLDED vao TOTAL @S185 - YC-020(1); rank-1 data-loss = MED)
# ---------------------------------------------------------------------------
# NAMING: script-local C-numbering (see C13 note). This implements S185 checklist C-11.
#
# WHY: the artifact roll-call machine (scripts/artifact-integrity.ps1) carries FOUR
# predicates - absent / empty / truncated / over-compressed - and is BLIND at the
# encoding level; the sub-w3 case proved it. The debt is on the books as "V-5 vi-tu
# ma-hoa CHUA CAM" in docs/governance/adap-reports/upgrade-pack-phased-bao-cao-cuoi.md:79
# (section 9, "No co moc"), where the V5 line is FILLED BY HAND with no machine behind
# it. The spec already existed - one grep line, TWO ranks - and was only waiting for a
# home. This section is that home, i.e. the hand-filled line becomes a measured count.
#
# TWO RANKS, kept apart on purpose (they need different actions):
#   rank-1 DATA LOSS      - raw U+FFFD present (UTF-8 bytes EF BF BD). The original bytes
#                           are GONE; re-decoding cannot bring them back, only the source
#                           artifact can. Detected on BYTES, so it is decode-independent.
#   rank-2 MOJIBAKE-ONLY  - no U+FFFD, but double-encoding signatures present. Nothing is
#                           lost; a correct re-decode repairs it.
# rank-1 dominates when a file shows both: "lost" is the actionable fact.
#
# USE vs MENTION is enforced at the BYTE level and that is not a detail: a previous
# baseline counted a file that writes the ASCII token "<FFFD>" seven times as if it held
# seven damaged characters, then a later reader saw the count "drop" and went hunting a
# phantom file. Matching raw EF BF BD only cannot make that mistake - a doc that TALKS
# about U+FFFD in ASCII is invisible to it (proved by the boundary case in the harness).
#
# CORPUS: $GovMd PLUS .claude/workflows/runs/**/*.md. The run folders are excluded from
# $GovMd by the C4 self-line rule (they describe patterns), but that rule is about
# CONTENT patterns and this predicate reads BYTES, so the exclusion buys nothing here and
# costs everything: measured @S185, 7 of the 12 damaged files live exactly there. An
# auditor that skipped runs/ has already reported a false 0-hit once.
#
# The mojibake markers are built from CODE POINTS (same U helper as the VN tokens) so this
# .ps1 stays pure-ASCII on disk (gotcha #30) and so the detector never carries a literal
# specimen of the thing it hunts.
Write-Section 'C15 - V-5 encoding predicate (TOTAL @S185)'
$c15Flags = 0
$c15Measured = $false

# Double-encoding signatures. Chosen for precision over recall: each is a Latin-1 lead
# byte followed by a continuation-range char, a pair that legitimate Vietnamese/English
# prose does not produce. Recall is deliberately partial - this is a lowering net.
# NOTE ON THE PARENTHESES, which are load-bearing: each element must be wrapped so the
# '+' concatenation cannot run ACROSS the commas. Written without them, PowerShell folds
# the three patterns into ONE string and the array silently becomes Count=1 - a net that
# then matches nothing and reports a confident 0. That is not hypothetical: the first
# measurement taken while building this section was exactly that shape and returned
# "mojibake: 0 files" on a corpus that really holds 13. Verified by printing
# ($MojiRx.Count = 3) and each element's code points before trusting any number.
$MojiRx = @(
    ((U @(0x00C3)) + '[' + (U @(0x00A0)) + '-' + (U @(0x00BF)) + ']'),
    ((U @(0x00E2, 0x20AC))),
    ((U @(0x00C4, 0x0091)))
)
# U+FFFD as its three UTF-8 bytes, seen through the Latin-1 view (EF BF BD).
$FFFD_BYTES = U @(0x00EF, 0x00BF, 0x00BD)

$v5Files = @($GovMd)
$runsDirV5 = Join-Path $RepoRoot '.claude\workflows\runs'
if (Test-Path $runsDirV5) {
    $v5Files += @(Get-ChildItem -Path $runsDirV5 -Recurse -Filter *.md -File -ErrorAction SilentlyContinue)
}
$v5Files = $v5Files | Where-Object { $_.FullName -notmatch '[\\/](bin|obj|node_modules)[\\/]' }

if ($v5Files.Count -eq 0) {
    Write-Host '  [skip] no .md in scope - encoding predicate measured NOTHING (not a green)' -ForegroundColor DarkGray
}
else {
    $c15Measured = $true
    $lossFiles = @(); $lossHits = 0
    $mojiFiles = @(); $mojiHits = 0
    foreach ($vf in $v5Files) {
        $bytes = $null
        try { $bytes = [System.IO.File]::ReadAllBytes($vf.FullName) } catch { $bytes = $null }
        if ($null -eq $bytes) { continue }
        # rank-1 counted on RAW BYTES via a Latin-1 view (codepage 28591 maps byte n ->
        # char U+00n, 1:1 and lossless), so the needle EF BF BD is matched exactly as
        # bytes while still using the fast .NET regex engine. A per-byte PowerShell loop
        # measured the same thing but took ~18 s longer over this corpus.
        $lat = [System.Text.Encoding]::GetEncoding(28591).GetString($bytes)
        $n = ([regex]::Matches($lat, $FFFD_BYTES)).Count
        if ($n -gt 0) {
            $lossFiles += ("{0} x{1}" -f (Rel $vf.FullName), $n); $lossHits += $n
            continue   # rank-1 dominates: a lost-byte file is not re-classified as merely mojibake
        }
        $txt = [System.Text.Encoding]::UTF8.GetString($bytes)
        $mm = 0
        foreach ($rx in $MojiRx) { $mm += ([regex]::Matches($txt, $rx)).Count }
        if ($mm -gt 0) { $mojiFiles += ("{0} x{1}" -f (Rel $vf.FullName), $mm); $mojiHits += $mm }
    }

    Write-Host ("  V-5 measured over {0} .md (docs + .claude + workflows/runs) ; rank-1 data-loss: {1} file(s) / {2} hit(s) ; rank-2 mojibake-only: {3} file(s) / {4} hit(s)" -f `
            $v5Files.Count, $lossFiles.Count, $lossHits, $mojiFiles.Count, $mojiHits)

    if ($lossFiles.Count -gt 0) {
        Write-Flag 'MED' 'V-5 rank-1' `
            ("encoding DATA LOSS: {0} file(s) / {1} raw U+FFFD - the original bytes are gone and no re-decode recovers them; only the source artifact can" -f $lossFiles.Count, $lossHits) `
            're-fetch each file from its source artifact (a re-save will NOT restore the lost characters); if a file is a frozen record, note the loss in it explicitly so it is not read as content'
        $c15Flags++
        foreach ($lf in $lossFiles) { Write-Host ("    [rank-1] {0}" -f $lf) -ForegroundColor DarkGray }
    }
    if ($mojiFiles.Count -gt 0) {
        Write-Flag 'LOW' 'V-5 rank-2' `
            ("encoding MOJIBAKE-ONLY: {0} file(s) / {1} double-encoding signature(s) - nothing is lost, a correct re-decode repairs it" -f $mojiFiles.Count, $mojiHits) `
            're-decode the file as UTF-8 and re-save; verify by re-running this detector (rank-2 must go to 0 without rank-1 rising)'
        $c15Flags++
        foreach ($mf in $mojiFiles) { Write-Host ("    [rank-2] {0}" -f $mf) -ForegroundColor DarkGray }
    }
    if ($lossFiles.Count -eq 0 -and $mojiFiles.Count -eq 0) {
        Write-Host '  [ok] no encoding damage found in scope' -ForegroundColor DarkGray
    }
}
$c15Note = if ($c15Measured) { '' } else { ' -- MEASURED NOTHING (skipped above); a 0 after a skip is not a green' }
Write-Host ("  C15 V-5 encoding flags = {0} [FOLDED vao TOTAL @S185 YC-020(1); rank-1 = MED]{1}" -f $c15Flags, $c15Note) -ForegroundColor DarkGray

# ---------------------------------------------------------------------------
# Summary + C4 self-exclusion audit (RUNTIME proof)
# ---------------------------------------------------------------------------
Write-Section 'Summary'

# Confirm 0 self-match: the detector script must never appear in the scanned set.
$selfPath = (Join-Path $RepoRoot 'scripts\governance-detectors.ps1') -replace '/', '\'
$selfInScan = @($GovMd | Where-Object { ($_.FullName -replace '/', '\') -ieq $selfPath }).Count
# (governance-detectors.ps1 is .ps1 not .md so never in $GovMd; this asserts the
#  invariant explicitly. Also assert none of the excluded dirs leaked in.)
$leaked = @($GovMd | Where-Object { Test-Excluded $_.FullName }).Count

Write-Host ("self-exclusion: {0} paths excluded (exact+dir rules)" -f $ExcludedActual.Count)
foreach ($e in $ExcludedActual) { Write-Host ("  - excluded: {0}" -f (Rel $e)) -ForegroundColor DarkGray }
Write-Host ("self-match check: governance-detectors.ps1 in scan = {0} ; leaked excluded files in scan = {1}" -f $selfInScan, $leaked)
if ($selfInScan -eq 0 -and $leaked -eq 0) {
    Write-Host '  [OK] 0 self-match (C4 satisfied)' -ForegroundColor Green
} else {
    Write-Host '  [!] self-exclusion LEAK - investigate Test-Excluded rules' -ForegroundColor Red
}

Write-Host ''
Write-Host ("TOTAL FLAGS: {0}" -f $script:FlagCount) -ForegroundColor Cyan
Write-Host ("INFORM-ONLY (new nets C6 cite-2-tier + H24-4 pending-flip + H24-5 counter-ancestor-classify + C9 hmw-width-drift + C11 h24-flag-form + C12 raw-engine-bypass + the L7-bolt 'chua do duoc' lines): {0} - counted SEPARATELY, NOT in TOTAL above (anti-Goodhart, owner-set; fold+sev-raise is post-triage)" -f $script:InformCount) -ForegroundColor DarkGray
Write-Host 'NOTE: DETECT-only lowering net. Exit 0 always (never fails build). FLAGs are advisory.' -ForegroundColor DarkGray

exit 0
