#Requires -Version 5.1 <# .SYNOPSIS H24 signal-write (M3, spec 2026-07-24-S149-hoi-tu-bookend + FIX-1). Writes the lead-self-audit tally to .claude/governance/.session-counter.json RIGHT AFTER the monitor pair (lead-stale-auditor + lead-gap-auditor) returns, so an audit that ran does not read "as if never run" (root-cause of the S146->S148 dropped-tally chain). .DESCRIPTION Mechanises the tally step that was previously done by hand (hand-copy dropped half of the view-* classes at S148, losing 2 FLAGs). Given the classes of the FLAGs that just fired, it: (1) validates EVERY class against the CLOSED enum read LIVE from memory-budget.json -> lead_self_audit.flag_classes. An unknown class => exit != 0 (a monitor may NOT invent a class; report verbatim + escalate to owner). NEVER hardcodes the enum. (2) PROCEDURAL max-1-decision/class/logic-session: reads the persisted per-session ledger (last_audit.signal_*); a class already decided this session is a NO-OP (one decision/class/session; the two bookend audits @start+@end collapse to one). (3) FIRE: counts[class] += 1 (deduped within a call). RESET (consecutive semantics): an enum class that did NOT fire this call AND has >= 1 in counts resets to 0 ONLY when (a) there is NO _frozen_until_owner key beside the map, and (b) no reset has happened yet this session. A present freeze key BLOCKS the reset and prints one line, leaving the owner-owned hung streak intact. (4) updates last_audit.{light|deep}_at_counter to the current counter. (5) writes ATOMICALLY (temp + Move-Item -Force) so a crash mid-write cannot wipe the owner-evidence file. (6) invariant: the counts map holds ONLY numbers (any note lives OUTSIDE the map; commit 2f39a7e removed a string that had been wedged into counts). Pure-ASCII source (gotcha #30: .ps1 stays ASCII-only). .PARAMETER Session Logic-session label, e.g. S149. Scopes the per-session decision ledger. .PARAMETER Flags Comma-separated list of the flag CLASSES that just fired (may repeat, may be empty). Example: 'gap-owner-specifics,gap-owner-specifics,view-stale-status'. Empty = a clean audit that fired nothing. .PARAMETER AuditKind 'light' or 'deep' - selects which last_audit.*_at_counter to stamp. .PARAMETER RepoRoot Repo root; both config paths derive from it. A temp tree here isolates fault-injection (mirror of governance-detectors.ps1 -RepoRoot convention). .OUTPUTS Exit 0 = written. Exit 2 = a class was outside the closed enum. Exit 3 = config/state error (missing enum, unreadable/unparseable counter, invariant violation). #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^S\d+')] [string]$Session, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Flags, [Parameter(Mandatory = $true)] [ValidateSet('light', 'deep')] [string]$AuditKind, # SO THU HAI (S172, owner slot 70). Class CO CA trong vong nay nhung KHONG thanh FLAG # - dien hinh: lead TU KHAI truoc khi con-do soi, nen con-do xep vao §0-bis NGOAI TOTAL. # Chung KHONG tang tally (khong phai FLAG moi) nhung CHAN RESET (ca co that = chuoi chua dut). [AllowEmptyString()] [string]$CoCa, [string]$RepoRoot ) $ErrorActionPreference = 'Stop' # Default RepoRoot in the BODY, not the param block: $PSScriptRoot is EMPTY while param # defaults evaluate under 'powershell.exe -File' (first-live failure S150; fault-inject K3 # passed only because every test passed -RepoRoot explicitly - happy-path did not cover the # default path). Mirrors session-counter-tick.ps1, which computes its default in the body. if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path } # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- # UTF-8 byte read + BOM strip. Mirrors governance-detectors.ps1:1424 - dodges bug E-010 # (ANSI-decode of UTF-8) AND the leading-BOM "Invalid JSON primitive" that a plain # UTF8.GetString would leave in front of the JSON. function Read-JsonFile { param([string]$Path) $raw = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($Path)) $raw = $raw.TrimStart([char]0xFEFF) return ($raw | ConvertFrom-Json) } # Set a property whether or not it already exists (Add-Member only adds new props). function Set-Prop { param($Obj, [string]$Name, $Value) if ($Obj.PSObject.Properties.Name -contains $Name) { $Obj.$Name = $Value } else { Add-Member -InputObject $Obj -MemberType NoteProperty -Name $Name -Value $Value } } # Read a counts entry as int; a missing key = 0. function Get-Count { param($Counts, [string]$Key) if ($Counts.PSObject.Properties.Name -contains $Key) { return [int]$Counts.$Key } return 0 } # Integer-like guard for the counts invariant. Rejects strings (the actual 2f39a7e bug), # booleans, doubles and nested objects; accepts integral numeric types. function Test-IntLike { param($Value) if ($Value -is [string]) { return $false } if ($Value -is [bool]) { return $false } if ($Value -is [int] -or $Value -is [long] -or $Value -is [int16] -or $Value -is [byte]) { return $true } return $false } # --------------------------------------------------------------------------- # resolve paths # --------------------------------------------------------------------------- $budgetPath = Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json' $counterPath = Join-Path $RepoRoot '.claude\governance\.session-counter.json' # --------------------------------------------------------------------------- # (1) read the LIVE closed enum (never hardcode) + validate every fired class # --------------------------------------------------------------------------- if (-not (Test-Path -LiteralPath $budgetPath)) { Write-Host "FAIL: budget not found: $budgetPath (cannot read the closed enum)" exit 3 } try { $budget = Read-JsonFile $budgetPath } catch { $budget = $null } if ($null -eq $budget -or $null -eq $budget.lead_self_audit -or $null -eq $budget.lead_self_audit.flag_classes) { Write-Host "FAIL: memory-budget.json is missing lead_self_audit.flag_classes (the CLOSED enum)." Write-Host " Refusing to invent a default - a hardcoded enum would silently re-create the drift the single-source exists to prevent." exit 3 } $enum = @($budget.lead_self_audit.flag_classes | ForEach-Object { [string]$_ }) # parse -Flags (comma list; may repeat; may be empty) $firedRaw = @() if (-not [string]::IsNullOrWhiteSpace($Flags)) { $firedRaw = @($Flags -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) } $bad = @($firedRaw | Where-Object { $enum -notcontains $_ } | Select-Object -Unique) if ($bad.Count -gt 0) { foreach ($b in $bad) { Write-Host ("FAIL: class '{0}' is NOT in the closed enum lead_self_audit.flag_classes." -f $b) } Write-Host (" A monitor may NOT invent a class - report it verbatim and escalate to the owner to extend the list.") Write-Host (" Closed enum ({0}): {1}" -f $enum.Count, ($enum -join ', ')) exit 2 } $firedUnique = @($firedRaw | Select-Object -Unique) # parse -CoCa (SO THU HAI, S172 slot 70). Cung enum DONG, cung luat cam tu-che class. $cocaRaw = @() if (-not [string]::IsNullOrWhiteSpace($CoCa)) { $cocaRaw = @($CoCa -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) } $badCoCa = @($cocaRaw | Where-Object { $enum -notcontains $_ } | Select-Object -Unique) if ($badCoCa.Count -gt 0) { foreach ($b in $badCoCa) { Write-Host ("FAIL: -CoCa class '{0}' is NOT in the closed enum lead_self_audit.flag_classes." -f $b) } Write-Host (" Closed enum ({0}): {1}" -f $enum.Count, ($enum -join ', ')) exit 2 } $cocaUnique = @($cocaRaw | Select-Object -Unique) # Giao voi -Flags: mot class vua FLAG vua tu-khai thi FLAG thang (no da tang tally roi). $cocaOnly = @($cocaUnique | Where-Object { $firedUnique -notcontains $_ }) # --------------------------------------------------------------------------- # read counter state # --------------------------------------------------------------------------- if (-not (Test-Path -LiteralPath $counterPath)) { Write-Host "FAIL: counter file not found: $counterPath" exit 3 } try { $ctr = Read-JsonFile $counterPath } catch { $ctr = $null } if ($null -eq $ctr) { Write-Host "FAIL: .session-counter.json is not valid JSON - refusing to write tally onto an unreadable state file." exit 3 } $counter = [int]$ctr.counter # ensure containers exist (defensive; the real file has them) if ($null -eq $ctr.last_audit) { Set-Prop $ctr 'last_audit' ([pscustomobject]@{}) } $la = $ctr.last_audit if ($null -eq $ctr.class_repeat) { Set-Prop $ctr 'class_repeat' ([pscustomobject]@{ counts = [pscustomobject]@{} }) } $cr = $ctr.class_repeat if ($null -eq $cr.counts) { Set-Prop $cr 'counts' ([pscustomobject]@{}) } $counts = $cr.counts # --------------------------------------------------------------------------- # freeze detection: _frozen_until_owner key present beside the map (class_repeat level) # --------------------------------------------------------------------------- $frozen = ($cr.PSObject.Properties.Name -contains '_frozen_until_owner') # --------------------------------------------------------------------------- # per-session decision ledger (persisted under last_audit; OUTSIDE the counts map). # The two bookend audits of one logic-session are separate script invocations, so the # ledger MUST persist in the file to collapse them into one decision/class/session. # --------------------------------------------------------------------------- $decided = @() $resetDone = $false if (([string]$la.signal_session) -eq $Session) { $sd = $la.signal_decided if ($null -ne $sd) { # robust to PS 5.1 array-serialization quirks (empty -> '' , single -> scalar) $decided = @($sd | Where-Object { ($_ -is [string]) -and ($_ -ne '') } | ForEach-Object { [string]$_ }) } if ($la.PSObject.Properties.Name -contains 'signal_reset_done') { $resetDone = [bool]$la.signal_reset_done } } # else: a new logic-session -> fresh ledger (decided empty, resetDone false) # --------------------------------------------------------------------------- # (3a) FIRE pass - dedup within a call; NO-OP a class already decided this session # --------------------------------------------------------------------------- foreach ($cls in $firedUnique) { if ($decided -contains $cls) { Write-Host ("NO-OP fire: class '{0}' already has a decision this session ({1}) - max-1-decision/class/session (procedural)." -f $cls, $Session) continue } $cur = Get-Count $counts $cls $new = $cur + 1 Set-Prop $counts $cls ([int]$new) $decided += $cls Write-Host ("FIRE: class '{0}' counts {1} -> {2}." -f $cls, $cur, $new) } # --------------------------------------------------------------------------- # (3b) RESET pass - consecutive semantics; at most one reset pass per session (FIX-1 p2); # a present freeze key blocks it entirely (owner-owned hung streak stays intact). # # 🔴 SO THU HAI chan reset (S172, owner slot 70 - vá incentive-inversion do ring2-audit A4 bắt). # BENH: "consecutive semantics" doc tin-hieu tu DUY NHAT danh-sach -Flags. Nhung mot class CO CA # THAT ma lead TU KHAI TRUOC khi con-do soi thi con-do xep no vao §0-bis NGOAI TOTAL => no vang # mat khoi -Flags => may doc thanh "khong tai dien" => RESET. Tuc co-che dang THUONG CHO VIEC GIAU: # im lang thi con-do bat va streak +1; tu thu thi streak ve 0. # CA THAT @S172: view-stale-header 4 -> 0, trong khi 2 header stale (STATUS:6, HANDOFF:5) CO THAT # va chinh lead da khai ra chung trong cung phien. # VA: reset chi khi class VANG MAT o CA HAI so - khong FLAG *VA* khong co ca tu-khai. # -Flags = "so FLAG moi" (tang tally) ⟂ -CoCa = "class nao co ca trong vong" (chi chan reset). # --------------------------------------------------------------------------- if (-not $resetDone) { $resetCandidates = @($enum | Where-Object { ($firedUnique -notcontains $_) -and ($cocaOnly -notcontains $_) -and ((Get-Count $counts $_) -ge 1) -and ($decided -notcontains $_) }) if ($resetCandidates.Count -gt 0) { if ($frozen) { Write-Host ("(frozen - cho owner xu dai JUMP): _frozen_until_owner present; NOT resetting {0} hung class(es): {1}" -f $resetCandidates.Count, ($resetCandidates -join ', ')) # nothing reset -> leave resetDone false and counts intact } else { foreach ($cls in $resetCandidates) { $cur = Get-Count $counts $cls Set-Prop $counts $cls ([int]0) Write-Host ("RESET: class '{0}' counts {1} -> 0 (khong FLAG VA khong co ca tu-khai - consecutive semantics)." -f $cls, $cur) } $resetDone = $true } } } # --------------------------------------------------------------------------- # (4) stamp last_audit.{light|deep}_at_counter = current counter # --------------------------------------------------------------------------- $floorKey = "${AuditKind}_at_counter" Set-Prop $la $floorKey ([int]$counter) # persist the per-session ledger (all OUTSIDE the counts map) Set-Prop $la 'signal_session' ([string]$Session) Set-Prop $la 'signal_decided' (@($decided)) Set-Prop $la 'signal_reset_done' ([bool]$resetDone) Set-Prop $la 'signal_last_kind' ([string]$AuditKind) # SO THU HAI - vet de doc lai duoc "vong nay class nao co ca ma khong thanh FLAG" Set-Prop $la 'signal_coca' (@($cocaOnly)) # --------------------------------------------------------------------------- # (6a) B10 @S152 enum-parity: EVERY enum class must exist as a key (0 if absent) so a # re-serialize can never silently drop an armed-at-0 class (ring2-deep S151: gap-underfill # was present-but-toothless - one round-trip from vanishing). Keys OUTSIDE the enum = # fail-loud, NOT silent delete (hidden data loss; owner extends enum or cleans by hand). # --------------------------------------------------------------------------- foreach ($cls in $enum) { if (-not ($counts.PSObject.Properties.Name -contains $cls)) { Set-Prop $counts $cls ([int]0) } } $extraKeys = @($counts.PSObject.Properties.Name | Where-Object { $enum -notcontains $_ }) if ($extraKeys.Count -gt 0) { Write-Host ("FAIL enum-parity: counts has {0} key(s) OUTSIDE the closed enum: {1} - refusing to write (silent delete hides data; extend lead_self_audit.flag_classes or clean by hand)." -f $extraKeys.Count, ($extraKeys -join ', ')) exit 3 } if (@($counts.PSObject.Properties).Count -ne $enum.Count) { Write-Host ("FAIL enum-parity: counts key-count {0} != enum count {1} after ensure - refusing to write." -f @($counts.PSObject.Properties).Count, $enum.Count) exit 3 } # --------------------------------------------------------------------------- # (6) invariant: counts map holds ONLY numbers (note lives OUTSIDE the map; 2f39a7e) # --------------------------------------------------------------------------- foreach ($p in $counts.PSObject.Properties) { if (-not (Test-IntLike $p.Value)) { $tn = if ($null -eq $p.Value) { 'null' } else { $p.Value.GetType().Name } Write-Host ("FAIL invariant: counts['{0}'] is not an integer (type {1}) - the counts map must hold ONLY numbers; any note goes OUTSIDE the map (commit 2f39a7e)." -f $p.Name, $tn) exit 3 } } # --------------------------------------------------------------------------- # (5) atomic write: temp + Move-Item -Force. PS 5.1 Out-File -Encoding utf8 emits a BOM; # both readers strip it (governance-detectors.ps1 byte+TrimStart, nhip-no-probe.ps1 # Get-Content -Encoding UTF8). # --------------------------------------------------------------------------- $json = $ctr | ConvertTo-Json -Depth 20 $tmp = "$counterPath.tmp" try { $json | Out-File -LiteralPath $tmp -Encoding utf8 Move-Item -LiteralPath $tmp -Destination $counterPath -Force } catch { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } Write-Host ("FAIL: atomic write failed ({0}) - original file left untouched." -f $_.Exception.Message) exit 3 } Write-Host ("OK: {0} | session={1} kind={2} counter={3} | fired=[{4}] decided-this-session=[{5}] reset-done={6} frozen={7}" -f ` $counterPath, $Session, $AuditKind, $counter, ($firedUnique -join '|'), ($decided -join '|'), $resetDone, $frozen) exit 0