#Requires -Version 5.1 <# .SYNOPSIS Harness-24 session-counter TICK - mechanizes the `_contract` block of .claude/governance/.session-counter.json (M2, spec S149 hoi-tu-bookend). .DESCRIPTION Turns the hand-run "tick + classify + append-history" ritual into a machine that touches disk deterministically (the S149 root-cause: SE was rules-rich / machine-poor, so ticks were done from memory and drifted: backfill S146, tally-drop S148, x27 != 33). Executes the `_contract` VERBATIM - it does NOT invent policy: tick(session, head): 1. read HEAD via git (content-addressed: a tick is keyed on the repo HEAD sha). 2. OR-guard idempotent: session == last_ticked_session OR head == last_ticked_head => NO-OP, exit 0, print one line. (The Stop-hook wal-flush.ps1 moves HEAD every turn-boundary, so the head-clause alone would let one real session tick twice; the session-label clause pins ONE label to exactly +1.) 3. classify BEFORE tick (two triggers, two verdicts - contract v2): (a) counter REGRESSION (incoming session number < stored) => FAIL-LOUD, exit!=0, no write. (b) last_ticked_head object MISSING (git cat-file fails) => FAIL-LOUD, exit!=0, no write. (c) last_ticked_head NOT reachable from HEAD but object EXISTS and counter did NOT regress => SQUASH-BENIGN: append ONE trace entry to history[] and CONTINUE (no owner alarm; a closeout squash routinely lifts the ticked wal:/session commit out of history). else (reachable + no regress) => CLEAN tick. 4. tick: counter+1; update the 4 fields (counter, last_ticked_session, last_ticked_head, last_ticked_at); append ONE history entry classed clean | squash-benign. 5. write ATOMICALLY: temp file then Move-Item -Force (khuon hub h17_cadence.py _save_atomic :174-184) - a crash mid-write leaves the original file intact. SCOPE GUARDS (per M2): does NOT touch `class_repeat` (that is M3) and does NOT reset anything. WRITE STRATEGY - why surgical string-replace, not ConvertTo-Json: Only Windows PowerShell 5.1 is present here (no pwsh 7 / no System.Text.Json). PS 5.1 ConvertTo-Json re-serializes the whole file with CRLF + char-escaping (measured: 25900 -> 28495 bytes) = a noisy diff on a committed governance file, and risks mangling the 8 emoji. So the file is PARSED with ConvertFrom-Json (for the guard logic) but WRITTEN by anchored line replacement + one array-insert on the raw UTF-8 text - preserving formatting byte-exact (measured surgical write: 25900 -> 25900 + one history entry, LF-only, emoji intact). Read/write both go through explicit UTF-8-no-BOM (E-010 trap: a missing -Encoding reads ANSI). .PARAMETER Session Session label, e.g. "S150". Must match ^S\d+ (the leading number is the monotonic guard). .PARAMETER RepoRoot Repo root. Default = the parent of this script's folder (the SE repo). Fault-injection (K2) points this at a throwaway git tree holding a copy of the counter json. .PARAMETER FaultStopAfterTemp K2(e) FAULT-INJECTION ONLY (default off): perform the real write up to and including the temp file, then STOP before Move-Item - simulating a crash between write and rename so a test can assert the ORIGINAL file is still intact. Never used in production call-sites. .EXAMPLE powershell -File scripts/session-counter-tick.ps1 -Session S150 .NOTES Contract source : .claude/governance/.session-counter.json (_contract, read verbatim) Hub reference : AI_INFRA/scripts/h17_cadence.py (_save_atomic :174-184) Caller (M6) : /session-start + /tiep call this instead of prose. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Session, [string]$RepoRoot, [switch]$FaultStopAfterTemp ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 # ------------------------------------------------------------------ output helpers function Write-Line([string]$msg) { [Console]::Out.WriteLine($msg) } function Fail-Loud([string]$msg, [int]$code) { # Single clear line to STDERR + non-zero exit (NOT a raw PowerShell exception dump). [Console]::Error.WriteLine("[h24-tick] FAIL-LOUD: $msg") exit $code } function Parse-SessionNum([string]$label) { if ([string]::IsNullOrWhiteSpace($label)) { return $null } $m = [regex]::Match($label, '^[Ss]?(\d+)') if ($m.Success) { return [int]$m.Groups[1].Value } return $null } # Anchored single-line replace on the raw text. Asserts EXACTLY one match (shape-drift = fail-loud), # and uses a literal MatchEvaluator so no '$' in the replacement is ever re-interpreted. function Replace-LineOnce([string]$text, [string]$pattern, [string]$newLine, [string]$what) { $rx = New-Object System.Text.RegularExpressions.Regex($pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) $n = $rx.Matches($text).Count if ($n -ne 1) { Fail-Loud "surgical replace '$what' expected exactly 1 match, found $n - counter-file shape drift; refusing to write." 7 } $ev = [System.Text.RegularExpressions.MatchEvaluator]({ param($m) $newLine }.GetNewClosure()) return $rx.Replace($text, $ev, 1) } function Json-Escape([string]$s) { # Minimal JSON string escaping. Event text is authored quote/backslash-free, but stay safe. $s = $s -replace '\\', '\\' $s = $s -replace '"', '\"' return $s } # Run git and return its trimmed stdout + real exit code. Native git writes to stderr on a # missing object / bad ref; under $ErrorActionPreference='Stop' PS 5.1 wraps that stderr as a # TERMINATING NativeCommandError (even with 2>$null), so classification could never run. Drop to # 'Continue' just around the call so a non-zero git exit is DATA we branch on, not a crash. function Invoke-GitLines([string]$root, [string[]]$gitArgs) { $old = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $out = & git -C $root @gitArgs 2>$null $code = $LASTEXITCODE } finally { $ErrorActionPreference = $old } return [pscustomobject]@{ Out = ("$out").Trim(); Code = $code } } # ------------------------------------------------------------------ resolve paths if ([string]::IsNullOrWhiteSpace($RepoRoot)) { if ([string]::IsNullOrWhiteSpace($PSScriptRoot)) { $RepoRoot = (Get-Location).Path } else { $RepoRoot = Split-Path $PSScriptRoot -Parent } } try { $RepoRoot = (Resolve-Path -LiteralPath $RepoRoot -ErrorAction Stop).Path } catch { Fail-Loud "RepoRoot does not exist: $RepoRoot" 8 } $counterFile = Join-Path $RepoRoot ".claude/governance/.session-counter.json" # ------------------------------------------------------------------ validate input if ($Session -notmatch '^S\d+') { Fail-Loud "invalid -Session '$Session' - expected an S label (e.g. S150)." 8 } # ------------------------------------------------------------------ read + parse counter file if (-not (Test-Path -LiteralPath $counterFile)) { Fail-Loud "counter file missing: $counterFile" 5 } $rawText = [System.IO.File]::ReadAllText($counterFile, [System.Text.Encoding]::UTF8) try { $data = $rawText | ConvertFrom-Json } catch { Fail-Loud "counter file is not valid JSON: $counterFile ($_)" 6 } $storedCounter = [int]$data.counter $storedSession = "$($data.last_ticked_session)" $storedHead = "$($data.last_ticked_head)" # ------------------------------------------------------------------ (1) HEAD via git $gh = Invoke-GitLines $RepoRoot @('rev-parse', 'HEAD') if ($gh.Code -ne 0 -or [string]::IsNullOrWhiteSpace($gh.Out)) { Fail-Loud "cannot resolve git HEAD in $RepoRoot (not a git repo?)." 4 } $headSha = $gh.Out # ------------------------------------------------------------------ (2) OR-guard idempotent if ($Session -eq $storedSession -or $headSha -eq $storedHead) { $reason = if ($Session -eq $storedSession) { "session-label '$Session' already ticked" } else { "HEAD $($headSha.Substring(0,7)) already ticked" } Write-Line "[h24-tick] NO-OP: $reason (stored $storedSession @ $($storedHead.Substring(0, [Math]::Min(7,$storedHead.Length)))); counter stays $storedCounter." exit 0 } # ------------------------------------------------------------------ (3) classify BEFORE tick # (3a) counter regression - incoming session number below the stored one = rollback / hand-edit. $inNum = Parse-SessionNum $Session $storedNum = Parse-SessionNum $storedSession if ($null -ne $storedNum -and $null -ne $inNum -and $inNum -lt $storedNum) { Fail-Loud "counter REGRESSION: incoming $Session (n=$inNum) < stored $storedSession (n=$storedNum) - the counter file was rolled back or hand-edited. Refusing to tick or write (contract fail_loud_on_regress trigger-1); report to owner." 2 } # (3b/3c) reachability classification of the stored head (skip if there is no prior head = fresh seed). $eventClass = 'clean' $reachNote = 'no prior head (fresh tick)' if (-not [string]::IsNullOrWhiteSpace($storedHead) -and $storedHead -ne 'null') { $gc = Invoke-GitLines $RepoRoot @('cat-file', '-t', $storedHead) $objExists = ($gc.Code -eq 0 -and $gc.Out -eq 'commit') if (-not $objExists) { Fail-Loud "last_ticked_head $storedHead is a MISSING object (git cat-file -t failed) - not reachable AND not present = possible tamper/rollback. Refusing to tick (contract fail_loud_on_regress trigger-2, missing branch); report to owner." 3 } $gm = Invoke-GitLines $RepoRoot @('merge-base', '--is-ancestor', $storedHead, $headSha) $reachable = ($gm.Code -eq 0) if ($reachable) { $reachNote = "reachable (merge-base --is-ancestor exit 0)" $eventClass = 'clean' } else { # object EXISTS + counter did NOT regress (checked above) => squash-benign, continue. $reachNote = "object EXISTS (cat-file=commit) but NOT reachable (merge-base --is-ancestor exit!=0), counter not regressed" $eventClass = 'squash-benign' } } # ------------------------------------------------------------------ (4) tick $newCounter = $storedCounter + 1 $gd = Invoke-GitLines $RepoRoot @('log', '-1', '--format=%cd', '--date=short', $headSha) if ($gd.Code -ne 0 -or [string]::IsNullOrWhiteSpace($gd.Out)) { $tickDate = (Get-Date -Format 'yyyy-MM-dd') } else { $tickDate = $gd.Out } $oldShort = $storedHead.Substring(0, [Math]::Min(7, $storedHead.Length)) $newShort = $headSha.Substring(0, 7) if ($eventClass -eq 'squash-benign') { $event = "squash-benign (session-counter-tick.ps1 M2, contract fail_loud_on_regress trigger-2 BENIGN branch): counter $storedCounter->$newCounter, session $storedSession->$Session, head $oldShort->$newShort. last_ticked_head $oldShort $reachNote => a closeout squash lifted the ticked wal:/session commit out of history (expected drift, not tamper). Trace appended, continue, no owner alarm. Written atomically (temp + Move-Item -Force)." } else { $event = "CLEAN tick (session-counter-tick.ps1 M2): counter $storedCounter->$newCounter, session $storedSession->$Session, head $oldShort->$newShort. Classify-before-tick: no regression (n=$inNum >= stored n=$storedNum); last_ticked_head $reachNote. 4 fields updated, 1 history entry appended, written atomically (temp + Move-Item -Force)." } $eventJson = Json-Escape $event # ---- surgical edits on the raw text (preserve formatting / emoji / LF byte-exact) ---- $new = $rawText $new = Replace-LineOnce $new '^ "counter": \d+,$' (' "counter": ' + $newCounter + ',') 'counter' $new = Replace-LineOnce $new '^ "last_ticked_session": "[^"]*",$' (' "last_ticked_session": "' + $Session + '",') 'last_ticked_session' $new = Replace-LineOnce $new '^ "last_ticked_head": "[^"]*",$' (' "last_ticked_head": "' + $headSha + '",') 'last_ticked_head' $new = Replace-LineOnce $new '^ "last_ticked_at": "[^"]*",$' (' "last_ticked_at": "' + $tickDate + '",') 'last_ticked_at' # ---- append ONE history entry: insert before the array close (anchored at EOF, exactly once) ---- $entry = " {`n" + " `"at`": `"$tickDate`",`n" + " `"session`": `"$Session`",`n" + " `"event`": `"$eventJson`"`n" + " }" $histRx = New-Object System.Text.RegularExpressions.Regex('(\n \})(\n \]\n\}\n?)$', [System.Text.RegularExpressions.RegexOptions]::Singleline) $histN = $histRx.Matches($new).Count if ($histN -ne 1) { Fail-Loud "history array-close anchor expected exactly 1 match at EOF, found $histN - counter-file shape drift; refusing to write." 7 } $histEv = [System.Text.RegularExpressions.MatchEvaluator]({ param($m) $m.Groups[1].Value + ",`n" + $entry + $m.Groups[2].Value }.GetNewClosure()) $new = $histRx.Replace($new, $histEv, 1) # ---- validate the result is still parseable BEFORE we touch disk ---- try { $null = $new | ConvertFrom-Json } catch { Fail-Loud "post-edit JSON failed to parse - aborting before write (no file touched): $_" 9 } # ------------------------------------------------------------------ (5) atomic write $tmp = $counterFile + '.tmp' $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($tmp, $new, $utf8NoBom) if ($FaultStopAfterTemp) { [Console]::Error.WriteLine("[h24-tick] FAULT-INJECT: wrote temp then STOPPED before Move-Item (simulated crash). Original intact: $counterFile ; dangling temp: $tmp") exit 42 } Move-Item -Force -LiteralPath $tmp -Destination $counterFile Write-Line "[h24-tick] ${eventClass}: ticked $storedSession -> $Session, counter $storedCounter -> $newCounter (HEAD $newShort). Wrote $counterFile atomically." exit 0