<# .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 ), 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//**`. 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//**"), 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