diff --git a/scripts/governance-detectors.ps1 b/scripts/governance-detectors.ps1 index 540f7fa..8d859ae 100644 --- a/scripts/governance-detectors.ps1 +++ b/scripts/governance-detectors.ps1 @@ -913,12 +913,15 @@ else { # log is not opening this session's log. The other three books accept any touch # (STATUS/HANDOFF/diaries are appended, not recreated). # -# HONEST LIMIT (surfaced, NOT silenced): a tail-close commit whose previous close is -# its own parent AND the same session batch yields a 1-commit window; that batch's -# real ritual lives in the excluded lower-bound commit, so the tail reads incomplete -# = a FALSE POSITIVE. The canonical spec baseline calls for per-session-label folding -# of same-batch closes; this build is per-commit as handed. Do NOT special-case it to -# green a report -- classify the flag and let the owner choose the grouping. +# 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. @@ -941,8 +944,14 @@ if ($gitTree) { $parts = $ln -split '\|', 2 if ($parts.Count -lt 2) { continue } if ($parts[1] -match $CloseoutSubjectRx) { - $lab = if ($parts[1] -match 'S(\d+)') { 'S' + $Matches[1] } else { 'S?' } - $closeouts += [pscustomobject]@{ Hash = $parts[0]; Short = $parts[0].Substring(0, 7); Label = $lab } + $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 } } } } @@ -955,15 +964,40 @@ elseif ($closeouts.Count -eq 0) { Write-Host ' [skip] no commit matches the closeout subject shape - nothing to score' -ForegroundColor DarkGray } else { - $take = [Math]::Min(3, $closeouts.Count) - Write-Host (" closeouts found: {0} ; scoring {1} most-recent (union-window per close)" -f $closeouts.Count, $take) + # 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++) { - $cur = $closeouts[$i] - if ($i + 1 -ge $closeouts.Count) { - Write-Host (" [skip] {0} {1}: no earlier close to open the window (oldest close in history) - not scored, not flagged" -f $cur.Short, $cur.Label) -ForegroundColor DarkGray + $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 = $closeouts[$i + 1] + $prev = $closeGroups[$i + 1].Tip $range = ("{0}..{1}" -f $prev.Hash, $cur.Hash) $union = @() @@ -981,10 +1015,10 @@ else { 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, $cur.Label, $prev.Short, $cur.Short) -ForegroundColor DarkGray + 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, $cur.Label) ` + 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' }