All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
275 lines
15 KiB
PowerShell
275 lines
15 KiB
PowerShell
#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)
|
|
}
|
|
|
|
# Style-preserving field replace (fix S151 format-fork): the S150 signal-write re-serialized
|
|
# this file via ConvertTo-Json (BOM + CRLF + two-space after colon), so the fixed-string
|
|
# replacer above stopped matching (0 match => exit 7 every session after every bookend).
|
|
# This variant's pattern carries TWO capture groups (prefix)(suffix); the matched prefix and
|
|
# suffix are kept verbatim, so whichever spacing/EOL shape the file has SURVIVES the write.
|
|
# Same exactly-one-match fail-loud as Replace-LineOnce (shape-drift still refuses to write).
|
|
function Replace-FieldOnce([string]$text, [string]$pattern, [string]$valueLiteral, [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) $m.Groups[1].Value + $valueLiteral + $m.Groups[2].Value }.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<nn> 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 (style-preserving: tolerate BOTH the original
|
|
# ---- surgical shape (LF / one-space) AND the ConvertTo-Json shape (CRLF / two-space) ----
|
|
$new = $rawText
|
|
$new = Replace-FieldOnce $new '^(\s{4}"counter":\s+)\d+(,\r?)$' ("$newCounter") 'counter'
|
|
$new = Replace-FieldOnce $new '^(\s{4}"last_ticked_session":\s+")[^"]*(",\r?)$' $Session 'last_ticked_session'
|
|
$new = Replace-FieldOnce $new '^(\s{4}"last_ticked_head":\s+")[^"]*(",\r?)$' $headSha 'last_ticked_head'
|
|
$new = Replace-FieldOnce $new '^(\s{4}"last_ticked_at":\s+")[^"]*(",\r?)$' $tickDate 'last_ticked_at'
|
|
|
|
# ---- append ONE history entry: insert before the array close (anchored at EOF, exactly once).
|
|
# Shape-tolerant (fix S151 format-fork): match ANY indent + CRLF-or-LF at EOF; the inserted
|
|
# entry reuses the file's own EOL and the LAST entry's own indent, so both shapes stay intact.
|
|
$histRx = New-Object System.Text.RegularExpressions.Regex('(\r?\n)([ \t]+)\}([ \t]*)(\r?\n[ \t]*\][ \t]*\r?\n\}[ \t]*\r?\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)
|
|
$eol = $m.Groups[1].Value
|
|
$ind = $m.Groups[2].Value # indent of the LAST entry's closing brace
|
|
$fld = $ind + ' ' # field indent = one level deeper
|
|
$entry = $ind + '{' + $eol +
|
|
$fld + '"at": "' + $tickDate + '",' + $eol +
|
|
$fld + '"session": "' + $Session + '",' + $eol +
|
|
$fld + '"event": "' + $eventJson + '"' + $eol +
|
|
$ind + '}'
|
|
$m.Groups[1].Value + $m.Groups[2].Value + '}' + $m.Groups[3].Value + ',' + $eol + $entry + $m.Groups[4].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
|