All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m48s
- 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>
251 lines
14 KiB
PowerShell
251 lines
14 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)
|
|
}
|
|
|
|
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 (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
|