<#
.SYNOPSIS
  closeout-sentinel.ps1 - M5 (S149 hoi-tu-bookend). Sentinel: "closeout co ghi so
  vong soi-lead khong" (did this closeout RECORD the audit round's signal on disk?).

.DESCRIPTION
  Given ONE closeout commit (-Commit <sha>), and IF an H24 audit ran that session
  (asserted via -AuditRan, OR auto-detected from an H24 run-folder of the same
  session), assert the closeout WROTE the audit signal to disk:

    (1) role-dir delta -- REQUIRED, drives the verdict:
        >=1 file delta (ANY path under the role dir, INCLUDING archive/*.gist.md,
        NOT only MEMORY.md) for the closeout audit-pair roles.
        Default -Roles = obs-pair {harvest-curator, tooling-auditor} (the pair that
        runs UNCONDITIONALLY @session-end and whose disk delta witnesses the closeout).
        Default semantics = AND (EVERY role must have a delta, VIPIX-faithful);
        -AnyRole relaxes to OR.

    (2) .session-counter.json touch -- AUXILIARY, reported as a sub-signal (WARN),
        NON-GATING by default. -RequireCounter promotes it to a hard gate (future
        state, after M2/M3 mechanize the counter-tick at closeout).

  Missing (1)  => verdict FLAG "audit do roi nhu chua do".
  Audit did NOT run this session => verdict SKIP (nothing to record; no false-flag).

  Advisory: exit code is ALWAYS 0. READ the `verdict:` line, NOT the exit code
  (mirrors VIPIX --nghi-thuc + spec/invest K6: "doc verdict: line, exit-code luon 0").

  ---------------------------------------------------------------------------
  DEVIATION vs VIPIX .claude/scripts/closeout-sentinel.js  (--nghi-thuc, line ~153)
  ---------------------------------------------------------------------------
  D1. SCOPE: VIPIX audits the THREE most-recent closeouts (Hub sec-B rule-of-3) and
      auto-detects each closeout via MARKERS (commit touches ALL of STATUS+HANDOFF+
      CRYSTALLIZED). SE audits ONE given commit (-Commit) -- like VIPIX's `--commit`
      forced mode -- because M5/K5 positive-control is defined per-commit
      (S140/S143/S146/S147/S148 individually).
  D2. PREDICATE PATH: VIPIX requires delta of `tooling-auditor/MEMORY.md` AND
      `harvest-curator/MEMORY.md` -- the MEMORY.md file SPECIFICALLY. SE broadens to
      ANY delta under `.claude/agent-memory/<role>/**`. RATIONALE (W2 on-disk measure):
      the S140 closeout `b146e2b` touched only harvest-curator/archive/2026-07.gist.md
      + tooling-auditor/archive/2026-07.gist.md (NOT MEMORY.md) -- a MEMORY.md-only
      predicate would FALSE-FLAG S140 (expected PASS). This is the spec-K5 fix
      ("predicate = delta BAT KY duoi agent-memory/<role>/**"), not a loosening: it
      still requires a real audit-pair delta, just not one pinned to a single filename.
  D3. GATE: VIPIX has no explicit "audit ran" gate (obs-pair runs unconditionally, so
      absence IS the signal). SE takes the commit as-given and gates on -AuditRan /
      auto-detect so pre-bookend sessions that genuinely skipped the audit are SKIPped,
      not false-flagged. In the target bookend world (hinh B) the pair runs every
      session, so -AuditRan is effectively always true.
  D4. COUNTER: VIPIX does not check `.session-counter.json`; SE surfaces it as an
      auxiliary WARN (see deviation-vs-task below).
  D5. RUNTIME: SE is Windows PowerShell 5.1, ASCII-only script body (gotcha #30); the
      output message 'audit do roi nhu chua do' is ASCII-transliterated for the same
      reason. VIPIX is Node.js (keeps Vietnamese diacritics).

  ---------------------------------------------------------------------------
  DEVIATION vs this-task literal wording (documented, NOT loosened -- see K5:
  "CAM noi predicate cho vua")
  ---------------------------------------------------------------------------
  T1. ROLE DEFAULT. The task text named the roles `lead-stale-auditor` /
      `lead-gap-auditor`. The K5 positive-control expected verdicts, however, are
      ONLY reproducible with the OBS-PAIR {harvest-curator, tooling-auditor} as the
      audit-pair-of-record. Evidence (all git-verified on the real repo):
        - invest sub-MD line 116 (the M5 design) names EXACTLY these two.
        - VIPIX :153 (the tailoring source) checks EXACTLY these two.
        - file-lists: S140/S143 touch the obs-pair (=> PASS); S146/S147/S148 do NOT
          (=> FLAG). CRUCIALLY S148 `932d607` touched lead-view-auditor/ +
          lead-omission-auditor/ (the OLD names of the lead-audit pair) AND
          .session-counter.json -- so a lead-audit-pair predicate would make S148
          PASS, contradicting the expected FLAG.
      => the positive-control role set is the obs-pair EXCLUSIVELY. -Roles defaults to
      it; it is CONFIGURABLE, so after M6 bookend-izes the lead-audit pair a caller may
      pass `-Roles lead-stale-auditor,lead-gap-auditor`. This is a ground-truth role
      CORRECTION, not a loosening (obs-pair AND-both is STRICTER than lead-pair OR).
  T2. COUNTER AS GATE. The task text made (2) a hard AND-condition
      ("... VA (2) counter cham. Thieu => FLAG"). That is mathematically incompatible
      with 5/5: S140 `b146e2b` does NOT touch .session-counter.json (git-verified) yet
      MUST be PASS. Any predicate [X AND counter-touched] yields S140=FLAG. Hence the
      counter cannot be a hard gate while 5/5 holds; spec K5 (the authoritative
      "predicate DA SUA theo W2") lists only role-dir-delta + the 5 cases, NOT the
      counter. Resolution: counter is computed and REPORTED (WARN if missing) but does
      not override a role-delta PASS; -RequireCounter promotes it to a gate for the
      mechanized future.

.PARAMETER Commit        Closeout commit sha (or any git rev). REQUIRED.
.PARAMETER AuditRan      Assert an H24 audit ran this session (skip auto-detect).
.PARAMETER Roles         Audit-pair role dir names under .claude/agent-memory/.
                         Default: harvest-curator, tooling-auditor (obs-pair).
.PARAMETER AnyRole       Relax role check to OR (any one role delta). Default = AND.
.PARAMETER RequireCounter  Promote the .session-counter.json touch to a hard gate.
.PARAMETER RepoRoot      Repo root. Default = scripts/ parent.

.EXAMPLE
  powershell.exe -ExecutionPolicy Bypass -File scripts/closeout-sentinel.ps1 -Commit 932d607 -AuditRan
#>
param(
    [Parameter(Mandatory = $true)][string]$Commit,
    [switch]$AuditRan,
    [string[]]$Roles = @('harvest-curator', 'tooling-auditor'),
    [switch]$AnyRole,
    [switch]$RequireCounter,
    [string]$RepoRoot
)

$ErrorActionPreference = 'Continue'

# Resolve RepoRoot robustly: $PSScriptRoot can be empty in PS 5.1 when the script is
# invoked via a RELATIVE -File path, so fall back through $PSCommandPath / MyInvocation
# / cwd (gotcha-adjacent to #30: keep the script self-locating).
if (-not $RepoRoot) {
    $scriptDir = $PSScriptRoot
    if (-not $scriptDir -and $PSCommandPath) { $scriptDir = Split-Path -Parent $PSCommandPath }
    if (-not $scriptDir -and $MyInvocation.MyCommand.Path) { $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path }
    if (-not $scriptDir) { $scriptDir = (Get-Location).Path }
    $RepoRoot = (Resolve-Path (Join-Path $scriptDir '..')).Path
}

$COUNTER = '.claude/governance/.session-counter.json'
$MEMROOT = '.claude/agent-memory'

# Normalize -Roles: when invoked from an EXTERNAL shell as `-Roles a,b`, PS 5.1 -File
# binding delivers the whole "a,b" as ONE string element (comma-split only happens for
# PS-native array literals). Split on commas so both `-Roles a,b` and `-Roles a b` work.
$Roles = @($Roles | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })

function Invoke-Git {
    param([string[]]$GitArgs)
    # Capture stdout only; do NOT redirect stderr (PS 5.1 wraps native stderr in
    # ErrorRecords). Caller checks $LASTEXITCODE.
    & git -C $RepoRoot @GitArgs
}

# --- resolve + validate commit --------------------------------------------
$sha = (Invoke-Git @('rev-parse', '--verify', "$Commit^{commit}"))
if ($LASTEXITCODE -ne 0 -or -not $sha) {
    Write-Host "closeout-sentinel: cannot resolve commit '$Commit' (not a git rev in $RepoRoot)"
    Write-Host "verdict   : ERROR resolve-commit"
    exit 0
}
$sha = ($sha | Select-Object -First 1).Trim()
$short = $sha.Substring(0, [Math]::Min(12, $sha.Length))
$subject = (Invoke-Git @('show', '-s', '--format=%s', $sha) | Select-Object -First 1)
$when = (Invoke-Git @('show', '-s', '--format=%cI', $sha) | Select-Object -First 1)

# --- file list touched by the commit (--root => root commits are safe) -----
$files = @(Invoke-Git @('diff-tree', '--no-commit-id', '--name-only', '-r', '--root', $sha) |
    ForEach-Object { $_.Trim() } | Where-Object { $_ })

# --- did an H24 audit run this session? ------------------------------------
$sess = $null
if ($subject -match 'S\d+') { $sess = $matches[0] }

$auditRanResolved = $false
$auditSource = ''
if ($AuditRan) {
    $auditRanResolved = $true
    $auditSource = 'asserted (-AuditRan)'
}
else {
    $runsDir = Join-Path $RepoRoot '.claude/workflows/runs'
    if ($sess -and (Test-Path $runsDir)) {
        $sessRe = '(?i)' + [regex]::Escape($sess) + '(?!\d)'
        $h24Marker = '(?i)h24|lead-(view|omission|stale|gap)|sub-lead'
        $cand = Get-ChildItem -Path $runsDir -Directory -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -match $sessRe }
        foreach ($c in $cand) {
            $nameHit = ($c.Name -match '(?i)h24')
            $fileHit = Get-ChildItem -Path $c.FullName -File -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -match $h24Marker } | Select-Object -First 1
            if ($nameHit -or $fileHit) {
                $auditRanResolved = $true
                $auditSource = "auto-detect run-folder: $($c.Name)"
                break
            }
        }
        if (-not $auditRanResolved) { $auditSource = "auto-detect: no H24 run-folder for $sess" }
    }
    else {
        $auditSource = 'auto-detect: no session token in subject / runs/ missing'
    }
}

# --- role-dir delta (predicate 1, drives verdict) --------------------------
$roleHits = @{}
foreach ($role in $Roles) {
    $prefix = "$MEMROOT/$role/"
    $hit = @($files | Where-Object { $_.StartsWith($prefix) })
    $roleHits[$role] = $hit
}
$rolesWithDelta = @($Roles | Where-Object { $roleHits[$_].Count -gt 0 })
if ($AnyRole) {
    $roleOk = ($rolesWithDelta.Count -gt 0)
    $roleMode = 'OR (any role)'
}
else {
    $roleOk = ($rolesWithDelta.Count -eq $Roles.Count)
    $roleMode = 'AND (every role)'
}

# --- counter touch (predicate 2, auxiliary) --------------------------------
$counterTouched = ($files -contains $COUNTER)

# --- verdict ---------------------------------------------------------------
Write-Host "-- closeout-sentinel (M5 / S149) -- commit $short  $when"
Write-Host "   subject : $subject"
Write-Host "   audit   : $(if ($auditRanResolved) { 'RAN' } else { 'NOT-RUN' })  [$auditSource]"

if (-not $auditRanResolved) {
    Write-Host "   => audit did not run this session; nothing to record."
    Write-Host "verdict   : SKIP audit-not-run  (khong co gi de ghi -- no false-flag)"
    exit 0
}

Write-Host "   roles   : [$($Roles -join ', ')]  match=$roleMode"
foreach ($role in $Roles) {
    $hit = $roleHits[$role]
    if ($hit.Count -gt 0) {
        Write-Host ("     + {0,-18} delta OK ({1} path): {2}" -f $role, $hit.Count, ($hit -join '; '))
    }
    else {
        Write-Host ("     - {0,-18} NO delta" -f $role)
    }
}
Write-Host "   counter : $(if ($counterTouched) { 'touched' } else { 'NOT touched' })  ($COUNTER)$(if ($RequireCounter) { '  [GATING]' } else { '  [auxiliary]' })"

$counterFail = ($RequireCounter -and (-not $counterTouched))

if ($roleOk -and (-not $counterFail)) {
    if ((-not $counterTouched) -and (-not $RequireCounter)) {
        Write-Host "   WARN    : .session-counter.json not touched (auxiliary; non-gating -- use -RequireCounter to enforce)"
    }
    Write-Host "OK: closeout recorded the audit round (role-dir delta present)."
    Write-Host "verdict   : PASS roles-with-delta=$($rolesWithDelta.Count)/$($Roles.Count) counter=$(if ($counterTouched) { 'y' } else { 'n' })"
    exit 0
}

# FLAG
$why = @()
if (-not $roleOk) { $why += "role-dir delta missing ($($rolesWithDelta.Count)/$($Roles.Count) roles have delta, need $roleMode)" }
if ($counterFail) { $why += ".session-counter.json not touched (-RequireCounter)" }
Write-Host "FLAG: audit do roi nhu chua do (audit measured but closeout recorded nothing)."
Write-Host "      reason: $($why -join ' ; ')"
Write-Host "      note  : FLAG = CANDIDATE (lead ground-truth). Stop-hook can split the"
Write-Host "              obs-pair delta into a separate 'wal:' commit => a healthy closeout"
Write-Host "              may still show missing (known FP, same as VIPIX :153)."
Write-Host "verdict   : FLAG roles-with-delta=$($rolesWithDelta.Count)/$($Roles.Count) counter=$(if ($counterTouched) { 'y' } else { 'n' })"
exit 0
