Files
solution-erp/scripts/session-counter-tick.ps1
pqhuy1987 1b85713bb6
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m38s
[CLAUDE] Docs: S151 closeout — bootstrap + trio-AUTO đầu tiên + gói 3-máy vá sống + bookend DEEP trả nợ JUMP (tally #53=53, AS-17 promote)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 20:13:25 +07:00

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