Files
solution-erp/scripts/h24-signal-write.ps1
pqhuy1987 398d343403
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m48s
[CLAUDE] Docs: S149-S150 closeout — bookend lượt đầu end-to-end (hình B) + 2 arc đóng + tally #53 chốt 45
- Arc-1 hội-tụ-bookend (rename 5 vai + 4 máy PS1 + C9-C12) + arc-2 adap-backlog 23/23 + probe Opus-5 khép (S149)
- Bookend @close S150: wave 6 vai (4 đo + 2 KIỂM) → 20 FLAG disposition từng-dòng; ring1 31/34-ĐẠT, ring2 10/10 + M-1 view-stale-role-desc
- Khoá _frozen_until_owner CẮM trước h24-signal-write (frozen-held in thật); 9 class FIRE; JUMP dải {5,4,4,3}+asym{1,4} chờ anh #21
- STATUS bump CURRENT S149-S150 + 2 khối Recently Done; HANDOFF segment mới + re-stamp đủ-slug + (17)(18)(19) lật + (14) viết lại + 6 slot #21-#26
- K2 posture-A @engine PHẦN K + K5 luật TÁCH vòng-đo-mới @session-end (O-2/O-3 OWNER-DELEGATED execute)
- sleep-doc derive-monitor-set (bỏ hardcode 4-tên); spawn-model-audit desc/comment fix; h24-signal-write param-default fix (first-live bug)
- M9 on-behalf ×3 (tooling + inv-cb F3 + reviewer F4); ring1/ring4 nhà seed; ring2 3 cite-dead vá
- Sổ garble #53 derive-from-body chốt 45 + sub-class ngược-#53/skeleton-ruột-rỗng (ghi-đĩa CẦN-KHÔNG-ĐỦ)
- Session-log S149-S150 (Sàn-5 đăng-ký 6 run=) + closeout-synthesis dạng-1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:17:05 +07:00

287 lines
13 KiB
PowerShell

#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,
[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)
# ---------------------------------------------------------------------------
# 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).
# ---------------------------------------------------------------------------
if (-not $resetDone) {
$resetCandidates = @($enum | Where-Object {
($firedUnique -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 (did not repeat this session - 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)
# ---------------------------------------------------------------------------
# (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