[CLAUDE] Infra: W1 - 2 detector H24 + 3 script governance (eol-hygiene / wal-recovery 4-ca / spawn-model-audit)

Wave adap-6-broadcast W1 - 4 lane disjoint qua /hmw RUN-TRACE (wf_de36dea9-265, 4/4 lane 0-error).
Lead TU CHAY LAI ca 4 + tu fault-inject, KHONG tin return (gotcha #53 fire 2/2 @W0.6).

(1) governance-detectors.ps1 455->715
    - GAP-3 +2 token canonical: menu=54 (STATUS.md:19); policy = row CHUA co (W2 land)
      -> FAIL-LOUD + SKIP, khong im lang gia vo xanh.
    - Detector H24-1 title-freshness: anchor_patterns[] = MANG 2 dang (fix C2). Bat buoc co dang
      '**Status (post Session N <em-dash> YYYY-MM-DD):**' vi permission-matrix/SKILL.md
      (positive-control SONG) dung dang do va KHONG co 'Last updated'. Neu 1 regex => detector
      loai thang chinh positive-control cua no => W4 bat-kha-thi.
      NEO-PHAM-VI load-bearing: STATUS.md:6 chua 2026-08-01 / 1987-03-07 / 2026-20-42 (cat tu
      mark-id). Regex-ngay-bat-ky lay max => 2026-20-42 = ngay KHONG ton tai => moc-phai rac
      => trai<phai khong bao gio dung => 0 flag VINH VIEN ma van xanh.
    - Detector H24-2 carry-age INFORM-only: M doc tu agent-memory/memory-budget.json
      h24_cadence.light_every; thieu key => FAIL-LOUD, 0 so nhip hardcode.
    - Bug lane tu bat: PS ten bien CASE-INSENSITIVE => $M (cadence) == $m (loop match) => ghi de
      cadence => vo nhanh fail-loud. exit 0 CHE bug nay; chi stderr moi lo.
    - M3 scope giu: 4 hang so owner-da-ky KHONG dung (walLineCount 40 / ratio / maxGotcha / liveVariants).

(2) agent-frontmatter-eol-check.ps1 (NEW) - HYGIENE-only
    W0.6 spawn-probe @S121 do duoc CRLF-TOLERANT (agent CRLF 276 byte CR spawn an du 4 tang;
    token 45248 LF vs 45240 CRLF, lech 8 = chenh chu trong prompt) => gia thuyet 'CRLF giet
    registry' BI BAC => script ha cap xuong hygiene, khai that ca o header lan thong diep FLAG
    runtime. Scope-chat 37 file (agents/commands/skills), KHONG repo-wide (188 = duong-gia
    factory). Tu in GREEN-BUT-VACUOUS thay vi nhan cong.

(3) wal-recovery-test.ps1 (NEW) - 4/4 ca, 30 assert
    FIX #3-bis: bien re nhanh = DAY RANGE, KHONG phai NONWAL. Counterfactual chay luat cu =>
    tai hien DEADLOCK that voi rebase-exit=0 (hong ma bao thanh cong). Assert
    sut-has-no-NONWAL-param bang Get-Command reflection, KHONG grep => ne tu-tham-chieu (W0.4#1).

(4) spawn-model-audit.ps1 (NEW) - PA-2b
    FAIL-LOUD khi thieu TIER2_EXPECTED_FULL_ID (W2 chua land). Muc E liet 6 dieu no KHONG chung,
    gom precedence spawn-param vs frontmatter (CHUA TEST, defer phien lead=Fable) + fix#8a chua thu.

VERIFY LEAD TU DO (khong phai so sub khai):
  TOTAL FLAGS 42->49 (>=46) . permission-matrix/SKILL.md FIRE 3 dong (W4 kha thi; KHONG lane nao
  cham file do) . exit 0 + stderr 0 x4 . non-ASCII 0/4 . teeth 2/2: inject 276 CR -> FLAG, go -> 0;
  inject claude-opus-9-9 -> FLAG mismatch, sua -> het. Containment sach.

LOI LEAD @S121 (di vao adap-report, khong im):
  (a) San-1 lech: marker wf: dang ky SAU khi phong (luat = TRUOC).
  (b) Khang dinh fact-tren-dia ma KHONG do dia: lead phan 'backfill [carry:*] = orphan chua ai lam'
      -> SAI, da co 7 khoa tu 4727d16 (S119, da push). Co che: ra BANG WAVE thay khong ai duoc
      giao => suy ra chua lam. 'Khong wave nao so huu' != 'chua co tren dia'. Brief lead co 4 tien
      de sai (config path / acceptance ngay 07-14 vs that 07-15 / carry / do-dai); lane (1) do dia va va het.

GAP THAT thay claim sai (lane 1 tim, lead verify dia): carry-age vacuous vi CAU TRUC - 45
logic-segment nhung carry-lines=1 => streak==1 => 0 fire vinh vien ke ca sau W2. Lo = thieu nghi
thuc RE-STAMP moi phien => giao W3 session-end.md, KHONG phai W2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-15 12:47:55 +07:00
parent 38bbcad416
commit d07bbb98c5
4 changed files with 1608 additions and 7 deletions

View File

@ -0,0 +1,335 @@
<#
.SYNOPSIS
agent-frontmatter-eol-check.ps1 - HYGIENE-only EOL check for agent/command/skill frontmatter.
NOT a fatal-defect guard. The "CRLF kills the agent registry" claim was REFUTED by experiment
(S121 W0.6) and this script must not be read, cited, or gated on as if it enforced that claim.
.DESCRIPTION
WHAT IT DOES
Enforces the .gitattributes policy line '* text=auto eol=lf' (repo root) on the frontmatter
of the rule-set files:
.claude/agents/*.md
.claude/commands/*.md
.claude/skills/**/SKILL.md
It reads RAW BYTES and counts CR (0x0D) inside the YAML frontmatter block.
WHAT IT IS NOT - the honest grade of this check (read before trusting it)
The S121 W0.6 spawn-probe MEASURED the opposite of the assumption that motivated this
script. A fully CRLF-ised agent file (276 CR bytes) spawned CORRECTLY through all four
stages: discovery / parse / spawn / execute. Token accounting was 45248 (LF) vs 45240
(CRLF); that 8-token delta is explained by wording differences in the prompt, not by EOL.
Verdict: CRLF-TOLERANT. The "LF-only frontmatter reader" hypothesis was REFUTED.
=> This is therefore HYGIENE, not defect-prevention. No CR finding produced here is known
to break anything. Do not describe it as fatal. Do not gate a build on it.
WHY IT STILL EXISTS - the scope limit of that refutation
The W0.6 measurement covers: BEHAVIOUR level only / .claude/agents/ only / ONE win32 build.
NOT covered: .claude/commands/, .claude/skills/, hook .ps1 readers, any other build or
platform. LF hygiene on those surfaces remains unverified-by-experiment, and the
.gitattributes eol=lf policy applies to them regardless of whether any reader currently
chokes. That residual - plus keeping the worktree consistent with the committed blob -
is the entire value proposition. It is a small one. It is stated at its real size on purpose.
SEVERITY LADDER (a deliberate consequence of the refutation)
HIGH - reserved for SELF-BROKEN only: the synthetic control below failed, so the scan
result is meaningless. Never used for a CR finding - none is known to break anything.
MED - CR inside the frontmatter block: the surface a parser would read, and the surface
W0.6 probed on agents/ only.
LOW - CR in the body only: same eol=lf policy, further from any reader hot-path.
Exit code is ALWAYS 0. Inform/hygiene net, never a hard gate.
POSITIVE CONTROL - SYNTHETIC, and declared as such (spec v2 (4)(a))
The scoped set measured 0 CR at S121, so a green run is the expected outcome. A green run
therefore proves NOTHING by itself: a detector that is merely broken is also green. Every
run self-tests the byte-reader against two IN-MEMORY buffers BEFORE touching disk:
(+) CRLF synthetic agent file -> reader MUST report frontmatter CR > 0
(-) LF synthetic agent file -> reader MUST report frontmatter CR = 0
These controls are synthetic BY NECESSITY, not by preference: because W0.6 refuted
CRLF-fatality, no real "file killed by CRLF" exists to serve as a natural positive control.
Note the limit of what a synthetic control buys: it proves the READER works. It does NOT
prove that CRLF harms anything - the experiment says it does not.
If either control fails, a HIGH self-broken FLAG is raised and the scan is reported
UNTRUSTWORTHY.
DESIGN NOTES
- Byte-reader, not text-reader: Get-Content (default and -Raw) drops or normalises CR, so a
text-level check could never fail on a CRLF file - it would be vacuous BY CONSTRUCTION.
[System.IO.File]::ReadAllBytes is used instead. Side effect: no text decoding happens at
all, so target-file encoding is irrelevant and gotcha #30 (ANSI-vs-UTF8 mojibake) cannot
reach the measurement. This script body is still ASCII-only per gotcha #30 / #37.
- No self-reference hazard (W0.4 lesson): the test is STRUCTURAL - count 0x0D inside a
byte-delimited region - not a string-grep, so it cannot match a quoted claim written
about itself. It also lives in scripts/ and scans only .claude/{agents,commands,skills},
so it is path-disjoint from its own scan set as well.
- Counts come from globs, never hardcoded (spec v3 fix #9h). Every PASS/FAIL/GREEN word
printed below is COMPUTED from a measured number (W0.4 meta-count lesson: never print a
verdict label next to a number that says otherwise).
- agents/README.md is intentionally IN scope. R2-C1 excludes README from the ROSTER count
(it is not an agent); EOL policy is per-file, so README is governed like any other file.
The two rules differ on purpose - do not "fix" one by copying the other.
- Scope is deliberately NOT repo-wide. A repo-wide 'i/lf w/crlf' scan matches 188 files
(131 of them EF Migrations); an acceptance of "count == 0" over that set would be a
false-price factory, and mass rm+checkout is forbidden this session (spec v2 W0.2).
The 188 stay INFORM-only and are not this script's business.
.PARAMETER RepoRoot
Repo root. Default = resolved 1 level up from this script (scripts/ -> repo root).
Every path is derived from it, so the script can be pointed at a temp tree for fault-injection.
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File scripts/agent-frontmatter-eol-check.ps1
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File scripts/agent-frontmatter-eol-check.ps1 -RepoRoot C:\Temp\eol-faultinject
#>
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
)
$ErrorActionPreference = 'Continue'
# ---------------------------------------------------------------------------
# Helpers - shape mirrors scripts/governance-detectors.ps1:43-67 so FLAG output
# from the two nets reads the same way.
# ---------------------------------------------------------------------------
$script:FlagCount = 0
function Write-Flag {
param(
[ValidateSet('HIGH', 'MED', 'LOW')] [string]$Severity,
[string]$Where, # file:line, or file
[string]$Desc,
[string]$Resolve
)
$color = switch ($Severity) { 'HIGH' { 'Red' } 'MED' { 'Yellow' } default { 'Gray' } }
Write-Host ("[EOL] {0,-4} | {1} | {2} | resolve: {3}" -f $Severity, $Where, $Desc, $Resolve) -ForegroundColor $color
$script:FlagCount++
}
function Write-Section($title) {
Write-Host ''
Write-Host ("===== $title =====") -ForegroundColor Cyan
}
# Make a path repo-relative for readable FLAG output (forward slashes).
function Rel($full) {
$r = $full
if ($full.StartsWith($RepoRoot, [StringComparison]::OrdinalIgnoreCase)) {
$r = $full.Substring($RepoRoot.Length).TrimStart('\', '/')
}
return ($r -replace '\\', '/')
}
# ---------------------------------------------------------------------------
# Byte-level frontmatter reader (the whole point - see DESIGN NOTES).
#
# Splits on 0x0A only, then strips one optional trailing 0x0D per line, so the
# frontmatter delimiters are located identically in LF and CRLF files. The
# returned offsets are BYTE offsets, which is what lets us attribute every single
# CR to either the frontmatter block or the body.
# ---------------------------------------------------------------------------
function Measure-EolBytes {
param([byte[]]$Bytes)
if ($null -eq $Bytes) { $Bytes = New-Object byte[] 0 }
$len = $Bytes.Length
# Skip a UTF-8 BOM so a BOM'd file does not silently read as "no frontmatter".
$start = 0
$hasBom = $false
if ($len -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) {
$hasBom = $true
$start = 3
}
# Locate the frontmatter block: line 0 must be exactly '---'; the block ends at
# the next line that is exactly '---'. $fmEnd = byte offset one past that line.
$fmEnd = -1
$lineStart = $start
$lineIdx = 0
for ($i = $start; $i -le $len; $i++) {
$atEof = ($i -eq $len)
if (-not $atEof -and $Bytes[$i] -ne 0x0A) { continue }
$endExcl = $i
if ($endExcl -gt $lineStart -and $Bytes[$endExcl - 1] -eq 0x0D) { $endExcl-- }
# Byte -> char is Latin-1 here; harmless because the only comparison is the
# ASCII delimiter '---'. Multi-byte UTF-8 in frontmatter cannot false-match it.
$sb = New-Object System.Text.StringBuilder
for ($k = $lineStart; $k -lt $endExcl; $k++) { [void]$sb.Append([char]$Bytes[$k]) }
$content = $sb.ToString().TrimEnd()
if ($lineIdx -eq 0) {
if ($content -ne '---') { break } # no frontmatter block at all
}
elseif ($content -eq '---') {
$fmEnd = if ($atEof) { $len } else { $i + 1 }
break
}
if ($atEof) { break } # opening '---' but never closed
$lineIdx++
$lineStart = $i + 1
}
# Attribute every CR byte. With no frontmatter block, the whole file counts as body.
$split = if ($fmEnd -ge 0) { $fmEnd } else { $start }
$fmCr = 0
$bodyCr = 0
for ($i = $start; $i -lt $len; $i++) {
if ($Bytes[$i] -eq 0x0D) {
if ($i -lt $split) { $fmCr++ } else { $bodyCr++ }
}
}
return [pscustomobject]@{
HasFrontmatter = ($fmEnd -ge 0)
HasBom = $hasBom
FmCr = $fmCr
BodyCr = $bodyCr
TotalCr = ($fmCr + $bodyCr)
Bytes = $len
}
}
function Measure-EolFile {
param([string]$Path)
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
}
catch {
Write-Host (" [warn] cannot read bytes: {0} - {1}" -f (Rel $Path), $_.Exception.Message) -ForegroundColor DarkGray
return $null
}
return (Measure-EolBytes $bytes)
}
# ---------------------------------------------------------------------------
# SYNTHETIC CONTROL - runs BEFORE the disk scan.
# Declared synthetic on purpose: W0.6 refuted CRLF-fatality, so there is no real
# dead file to use as a natural positive control (spec v2 (4)(a)).
# ---------------------------------------------------------------------------
Write-Section 'Synthetic control (DECLARED SYNTHETIC) - does the byte-reader have teeth?'
$ctrlCrlf = "---`r`nname: _synthetic-control`r`ndescription: synthetic positive control`r`n---`r`nbody line`r`n"
$ctrlLf = "---`nname: _synthetic-control`ndescription: synthetic negative control`n---`nbody line`n"
$ctrlPos = Measure-EolBytes ([System.Text.Encoding]::ASCII.GetBytes($ctrlCrlf))
$ctrlNeg = Measure-EolBytes ([System.Text.Encoding]::ASCII.GetBytes($ctrlLf))
$posOk = ($ctrlPos.HasFrontmatter -and $ctrlPos.FmCr -gt 0)
$negOk = ($ctrlNeg.HasFrontmatter -and $ctrlNeg.FmCr -eq 0 -and $ctrlNeg.BodyCr -eq 0)
$controlOk = ($posOk -and $negOk)
$posLabel = if ($posOk) { 'PASS' } else { 'FAIL' }
$negLabel = if ($negOk) { 'PASS' } else { 'FAIL' }
Write-Host (" (+) CRLF synthetic : fm={0} FmCr={1} BodyCr={2} expect fm=True,FmCr>0 -> {3}" -f `
$ctrlPos.HasFrontmatter, $ctrlPos.FmCr, $ctrlPos.BodyCr, $posLabel)
Write-Host (" (-) LF synthetic : fm={0} FmCr={1} BodyCr={2} expect fm=True,FmCr=0 -> {3}" -f `
$ctrlNeg.HasFrontmatter, $ctrlNeg.FmCr, $ctrlNeg.BodyCr, $negLabel)
if (-not $controlOk) {
Write-Flag 'HIGH' 'scripts/agent-frontmatter-eol-check.ps1 (self)' `
'self-broken: synthetic control FAILED - the byte-reader does not detect CR, or false-positives on LF. Every scan result below is MEANINGLESS.' `
'fix Measure-EolBytes; do NOT trust a green scan until both controls PASS'
}
else {
Write-Host ' [OK] reader detects CRLF and stays silent on LF - a green scan below is at least meaningful' -ForegroundColor Green
}
Write-Host ' (reminder: the control proves the READER works. It does NOT prove CRLF harms anything - S121 W0.6 measured that it does not.)' -ForegroundColor DarkGray
# ---------------------------------------------------------------------------
# Scoped scan - .gitattributes 'eol=lf' hygiene over the rule-set files.
# Scope per spec v2 W0.2 + R2. NOT repo-wide (see DESIGN NOTES).
# ---------------------------------------------------------------------------
Write-Section 'Scoped scan - .gitattributes eol=lf hygiene (.claude/{agents,commands,skills})'
$scopeSpecs = @(
[pscustomobject]@{ Name = '.claude/agents'; Dir = (Join-Path $RepoRoot '.claude\agents'); Filter = '*.md'; Recurse = $false },
[pscustomobject]@{ Name = '.claude/commands'; Dir = (Join-Path $RepoRoot '.claude\commands'); Filter = '*.md'; Recurse = $false },
[pscustomobject]@{ Name = '.claude/skills'; Dir = (Join-Path $RepoRoot '.claude\skills'); Filter = 'SKILL.md'; Recurse = $true }
)
$totalFiles = 0
$totalFmCrFiles = 0
$totalBodyCrFiles = 0
$totalNoFm = 0
$totalCrBytes = 0
foreach ($spec in $scopeSpecs) {
if (-not (Test-Path $spec.Dir)) {
Write-Host (" [skip] {0,-18} not present under RepoRoot" -f $spec.Name) -ForegroundColor DarkGray
continue
}
$files = @(Get-ChildItem -Path $spec.Dir -Filter $spec.Filter -File -Recurse:$spec.Recurse -ErrorAction SilentlyContinue)
$scopeScanned = 0
$scopeFmCr = 0
$scopeBodyCr = 0
$scopeCrBytes = 0
foreach ($f in $files) {
$m = Measure-EolFile $f.FullName
if ($null -eq $m) { continue }
$scopeScanned++
$totalFiles++
$scopeCrBytes += $m.TotalCr
$totalCrBytes += $m.TotalCr
if (-not $m.HasFrontmatter) { $totalNoFm++ }
if ($m.FmCr -gt 0) {
$scopeFmCr++
$totalFmCrFiles++
Write-Flag 'MED' (Rel $f.FullName) `
("eol-frontmatter-cr: {0} CR (0x0D) byte(s) inside the frontmatter block, {1} more in body - violates .gitattributes '* text=auto eol=lf'. HYGIENE only: S121 W0.6 measured a fully-CRLF agent file spawning correctly, so this is NOT a known breakage." -f $m.FmCr, $m.BodyCr) `
'rewrite the file with LF endings (git rm --cached <f> + git checkout -- <f>, or re-save as LF)'
}
elseif ($m.BodyCr -gt 0) {
$scopeBodyCr++
$totalBodyCrFiles++
Write-Flag 'LOW' (Rel $f.FullName) `
("eol-body-cr: frontmatter is clean but the body carries {0} CR (0x0D) byte(s) - same '* text=auto eol=lf' policy, further from any reader hot-path." -f $m.BodyCr) `
'rewrite the file with LF endings (hygiene / worktree-vs-blob consistency)'
}
}
Write-Host (" {0,-18} scanned={1,-3} frontmatter-CR-files={2,-3} body-only-CR-files={3,-3} CR-bytes={4}" -f `
$spec.Name, $scopeScanned, $scopeFmCr, $scopeBodyCr, $scopeCrBytes)
}
# ---------------------------------------------------------------------------
# Summary. Every label below is computed from a measured number (W0.4 lesson).
# ---------------------------------------------------------------------------
Write-Section 'Summary'
Write-Host ("scoped set (counted by glob, never hardcoded): {0} file(s)" -f $totalFiles)
Write-Host (" files with CR in frontmatter : {0}" -f $totalFmCrFiles)
Write-Host (" files with CR in body only : {0}" -f $totalBodyCrFiles)
Write-Host (" total CR (0x0D) bytes in set : {0}" -f $totalCrBytes)
Write-Host (" files with no parseable frontmatter block: {0} (not a flag - body CR still counted)" -f $totalNoFm)
Write-Host ''
Write-Host ("TOTAL FLAGS: {0} (scoped files={1}, CR bytes measured={2})" -f $script:FlagCount, $totalFiles, $totalCrBytes) -ForegroundColor Cyan
if (-not $controlOk) {
Write-Host 'RESULT: UNTRUSTWORTHY - the synthetic control FAILED, so the scan above proves nothing.' -ForegroundColor Red
}
elseif ($script:FlagCount -gt 0) {
Write-Host ("RESULT: {0} hygiene FLAG(s) above. Advisory only - exit code stays 0, nothing is known to be broken." -f $script:FlagCount) -ForegroundColor Yellow
}
elseif ($totalCrBytes -eq 0) {
Write-Host 'RESULT: GREEN-BUT-VACUOUS - 0 CR bytes existed in the scoped set, so this run had nothing to catch.' -ForegroundColor Yellow
Write-Host ' Do NOT bank this as an achievement: the scoped gate was ALREADY green with 0 work to do' -ForegroundColor DarkGray
Write-Host ' (spec v2 W0.2 / R2-C4). The only thing a green run demonstrates is that the reader itself' -ForegroundColor DarkGray
Write-Host ' works (synthetic control PASSED above). Its forward value is regression-catching, not this run.' -ForegroundColor DarkGray
}
else {
Write-Host 'RESULT: GREEN - CR bytes are present in the set but none in a flagged position.' -ForegroundColor Green
}
Write-Host 'NOTE: HYGIENE/INFORM net. Exit 0 always. CRLF-fatality was REFUTED by the S121 W0.6 spawn-probe (a fully-CRLF agent spawned OK through all 4 stages); this check enforces the .gitattributes eol=lf policy and nothing stronger.' -ForegroundColor DarkGray
exit 0

View File

@ -19,6 +19,11 @@
C3 - vocab-fork : alias-sets where >=2 variants live side-by-side. C3 - vocab-fork : alias-sets where >=2 variants live side-by-side.
C4 - self-line exclusion: pattern-describing files removed from every scan C4 - self-line exclusion: pattern-describing files removed from every scan
(else the detector self-matches). (else the detector self-matches).
H24-1 - title-freshness : doc's OWN title/status anchor date vs the newest
governance milestone date. ANCHOR-SCOPED parse.
H24-2 - carry-age : [carry:<slug>] keys alive across >= M consecutive
most-recent carry-lines. M read from config, never
hardcoded. INFORM-only.
Each FLAG line: Each FLAG line:
[DETECTOR] severity | file:line | description | resolve: <un-flag condition> (C5) [DETECTOR] severity | file:line | description | resolve: <un-flag condition> (C5)
@ -81,6 +86,12 @@ $VN_BANG = U @(0x62, 0x1EA3, 0x6E, 0x67) # "bang" (ta
$VN_DUTRU_PRO = U @(0x44, 0x1EF1, 0x20, 0x74, 0x72, 0xF9, 0x20, 0x50, 0x52, 0x4F) # "Du tru PRO" $VN_DUTRU_PRO = U @(0x44, 0x1EF1, 0x20, 0x74, 0x72, 0xF9, 0x20, 0x50, 0x52, 0x4F) # "Du tru PRO"
$VN_NGANSACH_PRO = U @(0x4E, 0x67, 0xE2, 0x6E, 0x20, 0x73, 0xE1, 0x63, 0x68, 0x20, 0x50, 0x52, 0x4F) # "Ngan sach PRO" $VN_NGANSACH_PRO = U @(0x4E, 0x67, 0xE2, 0x6E, 0x20, 0x73, 0xE1, 0x63, 0x68, 0x20, 0x50, 0x52, 0x4F) # "Ngan sach PRO"
# H24-1: EM DASH U+2014 (NOT hyphen-minus U+002D). Verified byte-level against
# .claude/skills/permission-matrix/SKILL.md:16 -> bytes "e2 80 94" = U+2014.
# Same runtime code-point trick as the VN tokens: an inline em-dash would be a
# non-ASCII byte in this .ps1 and would mojibake under the ANSI codepage decode.
$EM_DASH = U @(0x2014)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# C4 - self-line exclusion (BUILT FIRST so every scan can apply it) # C4 - self-line exclusion (BUILT FIRST so every scan can apply it)
# These files DESCRIBE the patterns the detectors look for; without exclusion # These files DESCRIBE the patterns the detectors look for; without exclusion
@ -151,13 +162,37 @@ if (-not (Test-Path $statusPath)) {
$canonicalOk = $false $canonicalOk = $false
} }
else { else {
$canonical['mig'] = Get-StatusValue $statusPath 'Migrations' # GAP-3 (H24): canonical token -> STATUS.md CURRENT-STATE row label.
$canonical['test'] = Get-StatusValue $statusPath 'Tests' # Row labels are READ OFF DISK, not guessed: 'Menu keys' is docs/STATUS.md:19
$canonical['gotcha'] = Get-StatusValue $statusPath 'Gotchas' # ("| Menu keys | **54** |"); 'Policies' does NOT exist yet -- W2 lands
$canonical['table'] = Get-StatusValue $statusPath 'SQL tables' # "| Policies | **216** |". A token whose row is absent must FAIL-LOUD and then
# be SKIPPED: silently resolving to $null would let the 'policy' half of GAP-3
# sit green while measuring nothing (a detector that cannot fire is worse than
# no detector -- it reads as coverage).
$canonRows = [ordered]@{
'mig' = 'Migrations'
'test' = 'Tests'
'gotcha' = 'Gotchas'
'table' = 'SQL tables'
'menu' = 'Menu keys'
'policy' = 'Policies'
}
foreach ($k in $canonRows.Keys) { $canonical[$k] = Get-StatusValue $statusPath $canonRows[$k] }
Write-Host (" STATUS.md canonical: mig={0} test={1} gotcha={2} table={3}" -f ` $canonShow = @()
$canonical['mig'], $canonical['test'], $canonical['gotcha'], $canonical['table']) foreach ($k in $canonRows.Keys) {
$v = $canonical[$k]
if ($null -eq $v) { $canonShow += ("{0}=MISSING" -f $k) } else { $canonShow += ("{0}={1}" -f $k, $v) }
}
Write-Host (" STATUS.md canonical: " + ($canonShow -join ' '))
foreach ($k in $canonRows.Keys) {
if ($null -eq $canonical[$k]) {
Write-Flag 'MED' (Rel $statusPath) `
("canonical row missing: no '| {0} | **<n>** |' row in CURRENT STATE -> token '{1}' UNRESOLVED and SKIPPED (scanning nothing, NOT green)" -f $canonRows[$k], $k) `
("add the '{0}' row to the docs/STATUS.md CURRENT STATE table" -f $canonRows[$k])
}
}
# ---- disk cross-check: canonical must not itself be stale ---- # ---- disk cross-check: canonical must not itself be stale ----
# mig = migration .cs files (exclude *Designer.cs / *ModelSnapshot.cs), recursive # mig = migration .cs files (exclude *Designer.cs / *ModelSnapshot.cs), recursive
@ -209,7 +244,12 @@ $countPatterns = @(
@{ Rx = '(\d+)\s*migration'; Key = 'mig'; Label = 'migration' }, @{ Rx = '(\d+)\s*migration'; Key = 'mig'; Label = 'migration' },
@{ Rx = '(\d+)\s*test'; Key = 'test'; Label = 'test' }, @{ Rx = '(\d+)\s*test'; Key = 'test'; Label = 'test' },
@{ Rx = ('(\d+)\s*(?:' + $VN_BAY + '|gotcha)'); Key = 'gotcha'; Label = 'gotcha/bay' }, @{ Rx = ('(\d+)\s*(?:' + $VN_BAY + '|gotcha)'); Key = 'gotcha'; Label = 'gotcha/bay' },
@{ Rx = ('(\d+)\s*(?:' + $VN_BANG + '|table)'); Key = 'table'; Label = 'table/bang' } @{ Rx = ('(\d+)\s*(?:' + $VN_BANG + '|table)'); Key = 'table'; Label = 'table/bang' },
# GAP-3 (H24): 'menu' + 'policy' were BLIND. Both resolve via $canonRows above;
# a token whose canonical row is missing is skipped by the $null guard below
# (already FAIL-LOUD flagged at resolve time), so 'policy' stays inert until W2.
@{ Rx = '(\d+)\s*menu'; Key = 'menu'; Label = 'menu' },
@{ Rx = '(\d+)\s*polic(?:y|ies)'; Key = 'policy'; Label = 'policy' }
) )
# H18-A(b) scan-range follows the REAL rule-range (S100, Harness-18 adopt): the derived-doc # H18-A(b) scan-range follows the REAL rule-range (S100, Harness-18 adopt): the derived-doc
@ -427,6 +467,226 @@ if (Test-Path $walPath) {
Write-Host ' (no .claude/WAL.md - no active chain, skip)' Write-Host ' (no .claude/WAL.md - no active chain, skip)'
} }
# ---------------------------------------------------------------------------
# H24-1 - title-freshness (do-tuoi-tieu-de)
# moc-phai (right edge) = newest governance milestone = MAX valid anchor date in corpus
# moc-trai (left edge) = the date carried by the doc's OWN title/status anchor
# FLAG when trai < phai. No anchor => SKIP (a doc that makes no freshness claim
# cannot make a STALE one).
#
# WHY ANCHOR-SCOPED, NOT "any date on the line" -- this is the load-bearing bit.
# Measured on disk: docs/STATUS.md:6 is a 69,870-char MEGA-LINE carrying 41
# date-shaped tokens. Most are NOT dates: owner-mark ids of the form
# RC-pqhuy1987-12-07-2026-11-43-45 get shredded by a bare \d{4}-\d{2}-\d{2} into
# GARBAGE pseudo-dates (1987-12-07 from the ...1987-12-07... slice, 2026-11-43,
# 2026-20-42 -- month 20, day 42). The line also holds a REAL future date
# (2026-08-01 = next monthly audit due). So:
# - date-max over the line -> 2026-20-42 (garbage) or 2026-08-01 (future)
# - either makes moc-phai unreachable => trai < phai never true => 0 flags forever
# => the detector silently strangles its OWN positive-control and reads green.
# Hence: capture ONLY the date bound to the anchor + strict calendar validation
# (TryParseExact rejects month-20/day-42 outright).
#
# WHY anchor_patterns IS A LIST (>=2 forms) -- also load-bearing:
# .claude/skills/*/SKILL.md carry NO "Last updated" anchor (grep "Last updated"
# in .claude/skills/ = 0 hit, verified). They use the OTHER form:
# "**Status (post Session N <em-dash> YYYY-MM-DD):**". A single "Last updated"
# regex + the "no anchor => skip" rule would drop permission-matrix/SKILL.md --
# the exact positive-control this detector exists to fire on.
# ---------------------------------------------------------------------------
Write-Section 'H24-1 - title-freshness'
$AnchorPatterns = @(
'\*\*Last updated:\*\*\s*(\d{4}-\d{2}-\d{2})',
('\*\*Status \(post Session \d+ ' + $EM_DASH + ' (\d{4}-\d{2}-\d{2})\)\:\*\*')
)
# docs/_archive/ is FROZEN-BY-DESIGN (verbatim pre-S40 snapshots kept as historical
# record). Their old anchor date is the POINT of the file, and the only "resolve"
# action -- refresh the date -- would destroy the record. A flag whose resolve is
# forbidden is permanent noise, so archives are out of THIS detector's scope.
# Scoped LOCALLY (not added to the global Test-Excluded) so C1/C3 keep scanning them
# exactly as before -- widening the global exclude would silently move other
# detectors' baselines, which is not this lane's call.
$TitleFreshSkip = @('\docs\_archive\')
function Get-AnchorDate {
param([string]$Path)
$ls = Get-Content -Path $Path -Encoding UTF8 -ErrorAction SilentlyContinue
if ($null -eq $ls) { return $null }
for ($i = 0; $i -lt $ls.Count; $i++) {
foreach ($rx in $AnchorPatterns) {
$m = [regex]::Match($ls[$i], $rx)
if ($m.Success) {
$raw = $m.Groups[1].Value
$dt = [datetime]::MinValue
$ok = [datetime]::TryParseExact($raw, 'yyyy-MM-dd',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::None, [ref]$dt)
if ($ok) {
return [pscustomobject]@{ Date = $dt; Raw = $raw; Line = ($i + 1) }
}
# anchor present but date not a real calendar date -> keep looking
}
}
}
return $null
}
$anchored = @()
foreach ($f in $GovMd) {
$p = ($f.FullName -replace '/', '\')
$skip = $false
foreach ($frag in $TitleFreshSkip) { if ($p -ilike "*$frag*") { $skip = $true } }
if ($skip) { continue }
$a = Get-AnchorDate $f.FullName
if ($null -ne $a) {
$anchored += [pscustomobject]@{
Rel = (Rel $f.FullName); Date = $a.Date; Raw = $a.Raw; Line = $a.Line
}
}
}
if ($anchored.Count -eq 0) {
Write-Host ' [skip] no doc carries a known title/status anchor - nothing to age-compare' -ForegroundColor DarkGray
}
else {
$newest = ($anchored | Sort-Object Date -Descending | Select-Object -First 1)
Write-Host (" anchors parsed: {0} doc(s) ; moc-phai (newest governance milestone) = {1} from {2}:{3}" -f `
$anchored.Count, $newest.Raw, $newest.Rel, $newest.Line)
foreach ($a in ($anchored | Sort-Object Date -Descending)) {
Write-Host (" anchor {0} {1}:{2}" -f $a.Raw, $a.Rel, $a.Line) -ForegroundColor DarkGray
}
foreach ($a in $anchored) {
if ($a.Date -lt $newest.Date) {
$age = [int]($newest.Date - $a.Date).TotalDays
Write-Flag 'LOW' ("{0}:{1}" -f $a.Rel, $a.Line) `
("title-stale: anchor says {0} but newest governance milestone is {1} ({2}d behind)" -f $a.Raw, $newest.Raw, $age) `
'refresh the title/status anchor date, or state explicitly that the doc is frozen-historical'
}
}
}
# ---------------------------------------------------------------------------
# H24-2 - carry-age (INFORM-only)
# A [carry:<slug>] key that survives >= M CONSECUTIVE most-recent carry-lines is
# "aged" -- it has outlived a full review cadence without being closed.
#
# "carry-line" (dong-carry) = a LOGIC segment, NOT a physical line. docs/HANDOFF.md
# is 12 physical lines but line 5 alone is a ~46.5K-char mega-line holding 45
# "NEXT anh" / "NEXT em" blocks (newest-first). Get-Content -TotalCount would see
# ONE line and measure nothing, so we read -Raw and split on the NEXT markers.
#
# Streak counts only over lines that HAVE carry: a session that emitted no carry
# does NOT break a chain. A key resets ONLY by being absent from a line that HAS
# carry. Only keys on the newest carry-line can hold a live streak (a key gone from
# the newest one is closed, not aged).
#
# M comes from config, NEVER hardcoded: a hardcoded cadence is exactly the
# single-source violation H24 forbids. Missing config/key => FAIL-LOUD + measure
# nothing. Scope note: this "no hardcoded cadence" rule is about the CADENCE number
# only -- pre-existing constants elsewhere in this script are other detectors'
# owner-signed numbers and are out of scope.
# ---------------------------------------------------------------------------
Write-Section 'H24-2 - carry-age (INFORM-only)'
# Canonical config path per owner-decision Q2. Probe agent-memory/ FIRST (that is
# where the live memory-budget.json actually is on disk); the bare .claude/ path is
# a fallback in case W2 lands the key at the shorter path some docs abbreviate to.
$cfgCandidates = @(
(Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json'),
(Join-Path $RepoRoot '.claude\memory-budget.json')
)
$cfgPath = $null
foreach ($c in $cfgCandidates) { if ($null -eq $cfgPath) { if (Test-Path $c) { $cfgPath = $c } } }
# NOTE: named $CadenceM, NOT $M. PowerShell variable names are CASE-INSENSITIVE, so a
# bare $M is the SAME variable as the $m used by the regex-match loops below -- the
# match object silently clobbered the cadence, making ($null -eq $M) false and turning
# the fail-loud path into "Could not compare 1 to [carry:bvaau]". Keep the long name.
$CadenceM = $null
if ($null -eq $cfgPath) {
Write-Flag 'MED' '.claude/agent-memory/memory-budget.json' `
'carry-age config NOT FOUND at any candidate path - cadence unresolved, carry-age measuring NOTHING' `
'create memory-budget.json carrying h24_cadence { light_every, deep_every, jump_on_class_repeat }'
}
else {
Write-Host (" config resolved: {0}" -f (Rel $cfgPath))
$cfg = $null
try { $cfg = (Get-Content -Path $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json) }
catch { $cfg = $null }
if ($null -eq $cfg) {
Write-Flag 'MED' (Rel $cfgPath) `
'carry-age config unparseable as JSON - cadence unresolved, carry-age measuring NOTHING' `
'fix the JSON syntax'
}
elseif ($null -eq $cfg.h24_cadence) {
Write-Flag 'MED' (Rel $cfgPath) `
'h24_cadence missing - W2 chua land => carry-age cadence UNRESOLVED, measuring NOTHING (no default is assumed: a hardcoded cadence would violate H24 single-source)' `
'W2: add h24_cadence { light_every, deep_every, jump_on_class_repeat }'
}
elseif ($null -eq $cfg.h24_cadence.light_every) {
Write-Flag 'MED' (Rel $cfgPath) `
'h24_cadence present but sub-key light_every missing - carry-age uses light_every as M, measuring NOTHING' `
'W2: add h24_cadence.light_every (the light-audit cadence a carry must not outlive)'
}
else {
$CadenceM = [int]$cfg.h24_cadence.light_every
Write-Host (" M = h24_cadence.light_every = {0} (read from config, not hardcoded)" -f $CadenceM)
}
}
$handoffPath = Join-Path $RepoRoot 'docs\HANDOFF.md'
if (-not (Test-Path $handoffPath)) {
Write-Host ' (no docs/HANDOFF.md - no carry surface, skip)' -ForegroundColor DarkGray
}
else {
$raw = Get-Content -Path $handoffPath -Raw -Encoding UTF8
# "NEXT anh" / "NEXT em" are pure ASCII (no diacritics) -> safe as a literal here.
$marks = [regex]::Matches($raw, 'NEXT\s+(?:anh|em)')
$segs = @()
for ($i = 0; $i -lt $marks.Count; $i++) {
$start = $marks[$i].Index
$end = if ($i + 1 -lt $marks.Count) { $marks[$i + 1].Index } else { $raw.Length }
$segs += $raw.Substring($start, $end - $start)
}
# Key charset excludes '<' so the FORMAT-SPEC literal "[carry:<slug>]" (prose in
# HANDOFF describing the convention) is never counted as a real key -- a detector
# that flags the sentence DEFINING its own pattern is the self-reference trap.
$carryRx = '\[carry:([a-z0-9][a-z0-9._-]*)\]'
$carryLines = @()
foreach ($s in $segs) {
$ks = @()
foreach ($cm in [regex]::Matches($s, $carryRx)) { $ks += $cm.Groups[1].Value }
if ($ks.Count -gt 0) { $carryLines += , (@($ks | Select-Object -Unique)) }
}
Write-Host (" HANDOFF logic-segments (NEXT anh/em) = {0} ; of those, carry-lines = {1}" -f `
$segs.Count, $carryLines.Count)
if ($carryLines.Count -eq 0) {
Write-Host ' (0 carry-line - no [carry:<slug>] stamped yet, nothing to age)' -ForegroundColor DarkGray
}
else {
foreach ($k in $carryLines[0]) {
$n = 0
for ($i = 0; $i -lt $carryLines.Count; $i++) {
if ($carryLines[$i] -contains $k) { $n++ } else { break }
}
if ($null -eq $CadenceM) {
Write-Host (" [inform] carry '{0}' streak={1} carry-line(s) ; M unresolved -> NO aged/not-aged verdict" -f $k, $n) -ForegroundColor DarkGray
}
elseif ($n -ge $CadenceM) {
Write-Flag 'LOW' ('docs/HANDOFF.md:5') `
("gap-carry-aged [INFORM]: carry '{0}' alive across {1} consecutive carry-lines (>= M={2}) - owner may be holding it deliberately" -f $k, $n, $CadenceM) `
("close it, or re-scope it; INFORM-only - no action forced")
}
else {
Write-Host (" [ok] carry '{0}' streak={1} < M={2}" -f $k, $n, $CadenceM) -ForegroundColor DarkGray
}
}
}
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Summary + C4 self-exclusion audit (RUNTIME proof) # Summary + C4 self-exclusion audit (RUNTIME proof)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -0,0 +1,471 @@
<#
.SYNOPSIS
spawn-model-audit.ps1 - H23 PA-2b: spawn-model audit, resolved-vs-expected. INFORMATIONAL.
.DESCRIPTION
Owner-decision S119 PA-2 (runs/2026-07-15-S119-adap-6-broadcast/owner-decisions-15-07-2026.md:59-60)
splits "anchor the model version" in two, because anchoring AT SPAWN is NOT possible at SE
(the spawn param takes an enum alias only; SE has 0 full-id in any definition file - H8 all-inherit):
PA-2a .claude/workflows/hmw.js declares TIER2_EXPECTED_FULL_ID = 'claude-opus-4-8'.
PA-2b THIS script compares the model actually RESOLVED at spawn against that constant.
Drift => print FLAG => owner decides re-pin. NO enforcement (H23 section 2(4)).
Design rules (mirror scripts/governance-detectors.ps1):
(1) NO-API - reads files only. NEVER calls a model/API.
(2) FLAG-only - prints FLAGs, NEVER edits files.
(3) PowerShell 5.1, offline. ASCII-only script body (gotcha #30); target files read -Encoding UTF8.
(4) DETECT-only. Exit code ALWAYS 0 (audit/inform, never a build gate).
---------------------------------------------------------------------------
MEASURABLE vs NOT-MEASURABLE - read before trusting any line of this report
---------------------------------------------------------------------------
MEASURED (this script really reads it, offline, from disk):
* expected = the TIER2_EXPECTED_FULL_ID constant in hmw.js [section A]
* resolved = "message":{"model":"..."} in the per-lane spawn transcripts
<TranscriptRoot>/<slug>/<session>/subagents/workflows/wf_*/agent-*.jsonl
Verified present S119: 9 lanes 'claude-opus-4-8' + 2 lanes 'claude-fable-5'. [section B]
NOT MEASURABLE HERE - declared, never silently passed:
(i) FORWARD drift. This audit is RETROSPECTIVE: it reads what ALREADY resolved in recorded
runs. It cannot pre-verify what the NEXT spawn will resolve to. A clean report does NOT
promise the next spawn stays on the expected version. This is exactly why H23 section 2(4)
makes it informational, not enforcement.
(ii) PRECEDENCE vs a frontmatter HARD-PIN (the hub form: 17/17 'model: claude-opus-4-8').
UNTESTED at SE and UNTESTABLE here: SE has 0 hard-pinned agents (12/12 'model: inherit',
owner order S66/H8). Only the inherit case has evidence - see the next block.
(iii)Whether the spawn param REJECTS a full-id (fix #8a). Per harness docs the param is an enum
alias (sonnet|opus|haiku|fable); passing a full-id has NEVER been tried. Not tried here.
(iv) Documentation coverage. This measures EXPLICITNESS IN TRANSCRIPTS only. A clean report must
NOT be read as "the floor is fully applied" (owner-decisions:65, honesty declaration 3).
PRECEDENCE - spawn-param vs frontmatter 'inherit': MEASURED 2026-07-15, no longer "never tested".
Spec v2:271 recorded this as CHUA TUNG DUOC TEST, reasoning that a discriminating experiment
needs lead=Fable. That reasoning only covered the tier:'opus' direction. The discriminating
condition is simply spawn-param family != lead family - and the tier:'fable' direction
satisfies it under lead=Opus. It ALREADY RAN, unplanned, in run wf_cb964f83-331:
lead model = claude-opus-4-8 (290/290 records, session 3d9bec56, zero Fable)
agentType = reviewer (.claude/agents/reviewer.md:5 = 'model: inherit')
spawn param = fable (/fable-real escape-hatch, hmw.js:44 returns the alias)
RESOLVED = claude-fable-5 (2/2 lanes, 39 + 42 assistant records)
If frontmatter 'inherit' had won, resolved would be the lead's claude-opus-4-8. It was not.
=> the spawn param TAKES EFFECT on a frontmatter-'inherit' agent => the GAP#6 fix is NOT a no-op.
HONEST LIMIT: this does not prove 'inherit' LOSES a precedence contest. 'inherit' may simply mean
"defer to the caller", in which case there is no contest at all. Both readings give the same
operative answer (the param takes effect); this data cannot separate them, and does not try.
.PARAMETER RepoRoot
Repo root. Default = 2 levels up from this script (scripts/ -> repo root).
Every path derives from this, so -RepoRoot <temp tree> makes fault-injection possible.
.PARAMETER TranscriptRoot
Root of the CLI project transcripts. Default derives from $env:USERPROFILE (NOT a hardcoded
absolute path). The per-project folder name is derived from $RepoRoot, so a fault-inject tree
resolves to a slug with no transcripts and section B reports NO-SCOPE instead of faking a number.
.PARAMETER SessionId
Audit one specific session folder. Default = most recent session that HAS spawn transcripts.
Default-to-current is deliberate: scanning old history surfaces pre-convention runs, which is
noise, not a bug storm (owner-decisions:64, honesty declaration 2).
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File scripts/spawn-model-audit.ps1
#>
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[string]$TranscriptRoot = (Join-Path $env:USERPROFILE '.claude\projects'),
[string]$SessionId = ''
)
$ErrorActionPreference = 'Continue'
# ---------------------------------------------------------------------------
# Owner-ratified expectation (S119 PA-2a, owner-decisions-15-07-2026.md:59).
# Deliberate 2-key design: the expected version is declared TWICE - once in hmw.js (PA-2a) and
# once here - so that a unilateral edit of ONE of them is FLAGGED instead of passing silently.
# Cost, stated plainly: an owner re-pin must touch BOTH files. That cost IS the check.
# NOT a fallback: if hmw.js has no constant, section A fails loud - it never borrows this value.
# ---------------------------------------------------------------------------
$OWNER_RATIFIED_TIER2_FULL_ID = 'claude-opus-4-8'
# Family that the 'opus' alias maps to. Alias locks the FAMILY, not the version (H23 section 2(2)):
# same family + different version = the drift this audit exists to catch.
$TIER2_FAMILY_PREFIX = 'claude-opus-'
# ---------------------------------------------------------------------------
# Helpers (shape mirrors scripts/governance-detectors.ps1:43-67)
# ---------------------------------------------------------------------------
$script:FlagCount = 0
function Write-Flag {
param(
[ValidateSet('HIGH', 'MED', 'LOW')] [string]$Severity,
[string]$Where,
[string]$Desc,
[string]$Resolve
)
$color = switch ($Severity) { 'HIGH' { 'Red' } 'MED' { 'Yellow' } default { 'Gray' } }
Write-Host ("[SPAWN-AUDIT] {0,-4} | {1} | {2} | resolve: {3}" -f $Severity, $Where, $Desc, $Resolve) -ForegroundColor $color
$script:FlagCount++
}
function Write-Section($title) {
Write-Host ''
Write-Host ("===== $title =====") -ForegroundColor Cyan
}
function Rel($full) {
$r = $full
if ($full.StartsWith($RepoRoot, [StringComparison]::OrdinalIgnoreCase)) {
$r = $full.Substring($RepoRoot.Length).TrimStart('\', '/')
}
return ($r -replace '\\', '/')
}
Write-Host ''
Write-Host '########## spawn-model-audit.ps1 - H23 PA-2b (INFORMATIONAL, exit 0 always) ##########' -ForegroundColor Cyan
Write-Host ("RepoRoot: {0}" -f $RepoRoot) -ForegroundColor DarkGray
# ===========================================================================
# SECTION A - PA-2a: the EXPECTED constant in hmw.js (STATIC, measurable)
# ===========================================================================
Write-Section 'A - expected constant (PA-2a, hmw.js)'
$hmwPath = Join-Path $RepoRoot '.claude\workflows\hmw.js'
$expected = $null
if (-not (Test-Path $hmwPath)) {
Write-Flag 'HIGH' (Rel $hmwPath) 'hmw.js not found - cannot resolve the expected model version' `
'run from a repo root that contains .claude/workflows/hmw.js'
}
else {
# STRUCTURAL anchor, NOT a loose string grep (W0.4 lesson 1 - self-reference).
# Capture the VALUE of the assignment. A bare grep for the version string is NOT acceptable
# evidence here: hmw.js:36 already contains the literal 'claude-opus-4-8' inside a COMMENT
# ("KHONG con demote-pin ..."), so a naive grep returns >=1 hit and passes VACUOUSLY even when
# the constant is absent or holds a wrong value. The naive count is printed below as a
# counter-example only - it is never used as evidence.
$RX_CONST = 'TIER2_EXPECTED_FULL_ID\s*=\s*[''"]([^''"]+)[''"]'
$constHits = @(Select-String -Path $hmwPath -Pattern $RX_CONST -Encoding UTF8 -AllMatches)
$naiveHits = @(Select-String -Path $hmwPath -Pattern ([regex]::Escape($OWNER_RATIFIED_TIER2_FULL_ID)) -Encoding UTF8)
Write-Host (" anti-vacuous note: naive grep '{0}' in hmw.js = {1} hit(s) [comment text] -> NOT used as evidence" -f `
$OWNER_RATIFIED_TIER2_FULL_ID, $naiveHits.Count) -ForegroundColor DarkGray
if ($constHits.Count -eq 0) {
# FAIL-LOUD. No hardcoded default is substituted: an audit that invents the value it is
# meant to verify would report a PASS about itself.
Write-Flag 'HIGH' (Rel $hmwPath) `
'expected constant missing (W2 chua land): TIER2_EXPECTED_FULL_ID not declared in hmw.js' `
("land PA-2a: add TIER2_EXPECTED_FULL_ID = '{0}' next to resolveModel() in hmw.js" -f $OWNER_RATIFIED_TIER2_FULL_ID)
Write-Host ' -> section B comparison will report SKIPPED (no expected value to compare against)' -ForegroundColor DarkGray
}
else {
$values = @()
foreach ($h in $constHits) {
foreach ($m in $h.Matches) { $values += $m.Groups[1].Value }
}
$distinctValues = @($values | Sort-Object -Unique)
Write-Host (" found {0} definition(s) of TIER2_EXPECTED_FULL_ID; distinct value(s): {1}" -f `
$values.Count, ($distinctValues -join ', '))
foreach ($h in $constHits) {
Write-Host (" {0}:{1}" -f (Rel $hmwPath), $h.LineNumber) -ForegroundColor DarkGray
}
if ($distinctValues.Count -gt 1) {
Write-Flag 'HIGH' (Rel $hmwPath) `
("conflicting definitions: TIER2_EXPECTED_FULL_ID declared with {0} different values [{1}]" -f `
$distinctValues.Count, ($distinctValues -join ' vs ')) `
'keep exactly ONE authoritative definition of TIER2_EXPECTED_FULL_ID'
}
$expected = $distinctValues[0]
# PASS/FAIL is COMPUTED from the measured value (W0.4 lesson 2 - never a label pasted
# next to a number that says otherwise).
if ($expected -cne $OWNER_RATIFIED_TIER2_FULL_ID) {
Write-Flag 'HIGH' (Rel $hmwPath) `
("mismatch vs owner-ratified expectation: hmw.js declares '{0}' but S119 PA-2a ratified '{1}'" -f `
$expected, $OWNER_RATIFIED_TIER2_FULL_ID) `
("set TIER2_EXPECTED_FULL_ID = '{0}', or have the owner re-ratify and update this script" -f $OWNER_RATIFIED_TIER2_FULL_ID)
}
else {
Write-Host (" [OK] hmw.js expected = '{0}' = owner-ratified (S119 PA-2a)" -f $expected) -ForegroundColor Green
}
if ($expected -match '\[1m\]') {
Write-Flag 'MED' (Rel $hmwPath) `
("expected value carries a '[1m]' context-window suffix: '{0}'" -f $expected) `
'drop the [1m] suffix - it is not part of the model id (gotcha #37)'
}
}
}
# ===========================================================================
# SECTION B - PA-2b: the RESOLVED model, from spawn transcripts (MEASURED)
# ===========================================================================
Write-Section 'B - resolved model (PA-2b, spawn transcripts)'
# Project folder name derives from $RepoRoot (path separators -> '-'), so a fault-inject tree
# resolves to a slug with no transcripts -> NO-SCOPE, never a faked measurement.
$slug = $RepoRoot -replace '[:\\/_]', '-'
$projDir = Join-Path $TranscriptRoot $slug
$dispatch = 0 # spawn lanes found (one agent-*.jsonl = one lane)
$explicit = 0 # lanes where a resolved model is actually recorded
$mismatch = 0 # lanes resolved to the tier-2 FAMILY but NOT the expected version = alias drift
$otherFam = 0 # lanes resolved outside the tier-2 family (escape-hatch / inherited lead)
$modelTally = @{}
Write-Host (" transcript root: {0}" -f $TranscriptRoot) -ForegroundColor DarkGray
Write-Host (" project slug : {0} (derived from RepoRoot)" -f $slug) -ForegroundColor DarkGray
if (-not (Test-Path $projDir)) {
Write-Host ' [NO-SCOPE] no transcript folder for this repo - nothing to measure, nothing claimed.' -ForegroundColor DarkGray
Write-Host ' (expected when running against a fault-inject / temp tree)' -ForegroundColor DarkGray
}
else {
$sessionDir = $null
if ($SessionId -ne '') {
$cand = Join-Path $projDir $SessionId
if (Test-Path $cand) { $sessionDir = Get-Item $cand }
else { Write-Flag 'MED' (Rel $cand) 'requested -SessionId not found' 'pass an existing session id, or omit to use the most recent' }
}
else {
$sessionDir = Get-ChildItem -Path $projDir -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'subagents\workflows') } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
}
if ($null -eq $sessionDir) {
Write-Host ' [NO-SCOPE] no session with spawn transcripts under this project - nothing measured.' -ForegroundColor DarkGray
}
else {
Write-Host (" session scope : {0} (last write {1:yyyy-MM-dd HH:mm})" -f $sessionDir.Name, $sessionDir.LastWriteTime)
Write-Host ' scope = CURRENT session only, by design (old runs predate the convention; owner-decisions:64)' -ForegroundColor DarkGray
# STRUCTURAL anchor: the model key of an assistant record sits immediately inside
# "message":{ ... . Anchoring on the record shape (not a bare "model": grep) keeps quoted
# JSON inside message CONTENT - agents routinely paste transcript snippets - from being
# counted as if it were a real dispatch.
$RX_MODEL = '"message"\s*:\s*\{\s*"model"\s*:\s*"([^"]+)"'
$wfRoot = Join-Path $sessionDir.FullName 'subagents\workflows'
$laneFiles = @(Get-ChildItem -Path $wfRoot -Recurse -Filter 'agent-*.jsonl' -File -ErrorAction SilentlyContinue)
foreach ($lane in $laneFiles) {
$dispatch++
$hits = @(Select-String -Path $lane.FullName -Pattern $RX_MODEL -Encoding UTF8 -AllMatches)
$models = @()
foreach ($h in $hits) {
foreach ($m in $h.Matches) { $models += $m.Groups[1].Value }
}
$distinct = @($models | Sort-Object -Unique)
$wfName = Split-Path (Split-Path $lane.FullName -Parent) -Leaf
$agentType = '?'
$metaPath = ($lane.FullName -replace '\.jsonl$', '.meta.json')
if (Test-Path $metaPath) {
$metaTxt = Get-Content -Path $metaPath -Raw -Encoding UTF8
$mm = [regex]::Match($metaTxt, '"agentType"\s*:\s*"([^"]+)"')
if ($mm.Success) { $agentType = $mm.Groups[1].Value }
}
if ($distinct.Count -eq 0) {
Write-Host (" {0}/{1} [{2}] -> no model recorded (lane produced no assistant record)" -f `
$wfName, $lane.Name, $agentType) -ForegroundColor DarkGray
continue
}
$explicit++
foreach ($d in $distinct) {
if ($modelTally.ContainsKey($d)) { $modelTally[$d] = $modelTally[$d] + 1 }
else { $modelTally[$d] = 1 }
}
$verdict = 'other-family'
$inFamily = @($distinct | Where-Object { $_.StartsWith($TIER2_FAMILY_PREFIX) })
if ($null -eq $expected) {
$verdict = 'SKIPPED (no expected constant)'
}
elseif ($inFamily.Count -eq 0) {
$verdict = 'other-family (escape-hatch / inherited lead - not a drift signal)'
$otherFam++
}
else {
$bad = @($inFamily | Where-Object { $_ -cne $expected })
if ($bad.Count -gt 0) {
$mismatch++
$verdict = 'MISMATCH'
Write-Flag 'HIGH' ("{0}/{1}" -f $wfName, $lane.Name) `
("alias-drift: resolved '{0}' is in the tier-2 family but expected '{1}'" -f ($bad -join ', '), $expected) `
'owner decides re-pin (H23 section 2(2)); update TIER2_EXPECTED_FULL_ID once ratified'
}
else {
$verdict = 'match'
}
}
Write-Host (" {0}/{1} [{2}] -> {3} ({4} record(s)) : {5}" -f `
$wfName, $lane.Name, $agentType, ($distinct -join ', '), $models.Count, $verdict)
}
Write-Host ''
Write-Host (" COUNTS: {0} dispatch | {1} explicit | {2} mismatch" -f $dispatch, $explicit, $mismatch)
Write-Host ' (dispatch = lanes found | explicit = lanes with a model actually recorded | mismatch = tier-2-family lanes off the expected version)' -ForegroundColor DarkGray
if ($modelTally.Count -gt 0) {
Write-Host ' resolved model tally (lanes per model):'
foreach ($k in ($modelTally.Keys | Sort-Object)) {
Write-Host (" {0,-24} {1} lane(s)" -f $k, $modelTally[$k])
}
}
if ($null -eq $expected) {
Write-Host ' [SKIPPED] resolved-vs-expected NOT evaluated: section A found no expected constant.' -ForegroundColor Yellow
Write-Host ' This is NOT a pass. The lanes above are reported raw, uncompared.' -ForegroundColor Yellow
}
elseif ($dispatch -eq 0) {
Write-Host ' [NO-SCOPE] 0 lanes in scope - a 0-mismatch count here would be VACUOUS, not a pass.' -ForegroundColor Yellow
}
elseif ($mismatch -eq 0) {
Write-Host (" [OK] 0/{0} lane(s) drifted off expected '{1}' (retrospective only - says nothing about the NEXT spawn)" -f `
$explicit, $expected) -ForegroundColor Green
}
}
}
# ===========================================================================
# SECTION C - canonical cross-check: docs/STATUS.md, the Sub-agents row
# ===========================================================================
Write-Section 'C - canonical cross-check (STATUS.md Sub-agents row)'
# Row label read from the file, not guessed: docs/STATUS.md:24 = "| Sub-agents | **12** | ...".
# That row is the single-owner flip-chain canonical; hmw.js:34 and agents/README point at it.
$SUBAGENT_ROW_LABEL = 'Sub-agents'
$FLIPCHAIN_MARKER = 'CANONICAL single-owner flip-chain'
$statusPath = Join-Path $RepoRoot 'docs\STATUS.md'
if (-not (Test-Path $statusPath)) {
Write-Flag 'MED' (Rel $statusPath) 'docs/STATUS.md not found - cannot cross-check the roster canonical' `
'run against a repo root that contains docs/STATUS.md'
}
else {
$rowPat = '^\|\s*' + [regex]::Escape($SUBAGENT_ROW_LABEL) + '\s*\|\s*\*\*(\d+)'
$rowHit = Select-String -Path $statusPath -Pattern $rowPat -Encoding UTF8 | Select-Object -First 1
if ($null -eq $rowHit) {
Write-Flag 'MED' (Rel $statusPath) `
("canonical row '| {0} | **N** |' not found in STATUS.md CURRENT STATE table" -f $SUBAGENT_ROW_LABEL) `
'restore the Sub-agents row, or update this script if the canonical owner moved'
}
else {
$canonSub = [int]$rowHit.Matches[0].Groups[1].Value
Write-Host (" STATUS.md:{0} canonical {1} = {2}" -f $rowHit.LineNumber, $SUBAGENT_ROW_LABEL, $canonSub)
if ($rowHit.Line -notmatch [regex]::Escape($FLIPCHAIN_MARKER)) {
Write-Flag 'LOW' ("{0}:{1}" -f (Rel $statusPath), $rowHit.LineNumber) `
("the {0} row no longer carries the '{1}' marker" -f $SUBAGENT_ROW_LABEL, $FLIPCHAIN_MARKER) `
'restore the marker, or re-point this cross-check at the new canonical owner'
}
else {
Write-Host (" [OK] row carries the flip-chain canonical marker") -ForegroundColor Green
}
# disk cross-check: the canonical count must not itself be stale.
$agentDir = Join-Path $RepoRoot '.claude\agents'
if (Test-Path $agentDir) {
$diskAgents = @(Get-ChildItem -Path $agentDir -Filter *.md -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne 'README.md' })
Write-Host (" disk cross-check: .claude/agents/*.md minus README = {0}" -f $diskAgents.Count)
if ($diskAgents.Count -ne $canonSub) {
Write-Flag 'MED' (Rel $statusPath) `
("canonical-itself-stale: STATUS {0}=**{1}** but disk has {2} agent .md" -f `
$SUBAGENT_ROW_LABEL, $canonSub, $diskAgents.Count) `
("re-ground the STATUS.md {0} row to {1}" -f $SUBAGENT_ROW_LABEL, $diskAgents.Count)
}
else {
Write-Host (" [OK] canonical {0} matches disk ({1})" -f $canonSub, $diskAgents.Count) -ForegroundColor Green
}
}
}
}
# ===========================================================================
# SECTION D - H8 all-inherit: agent frontmatter
# ===========================================================================
Write-Section 'D - frontmatter all-inherit (H8)'
# GLOB, never a hardcoded roster size: the roster moves 12 -> 14 at W2, and a hardcoded count
# would self-age into a false FAIL the moment it lands (fix #9(h) / R2-M3).
$agentDir = Join-Path $RepoRoot '.claude\agents'
if (-not (Test-Path $agentDir)) {
Write-Flag 'MED' (Rel $agentDir) '.claude/agents not found - cannot check frontmatter' `
'run against a repo root that contains .claude/agents'
}
else {
$agentFiles = @(Get-ChildItem -Path $agentDir -Filter *.md -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne 'README.md' })
$inheritCount = 0
foreach ($a in $agentFiles) {
$lines = @(Get-Content -Path $a.FullName -Encoding UTF8)
# frontmatter = the block between the first '---' and the next '---'
$modelVal = $null
$modelLine = 0
$inFm = $false
for ($i = 0; $i -lt $lines.Count; $i++) {
$t = $lines[$i].Trim()
if ($t -eq '---') {
if (-not $inFm) { $inFm = $true; continue }
break
}
if ($inFm -and $t -match '^model\s*:\s*(.+?)\s*$') {
$modelVal = $Matches[1].Trim()
$modelLine = $i + 1
}
}
if ($null -eq $modelVal) {
Write-Flag 'MED' (Rel $a.FullName) `
'no "model:" key in frontmatter - the lane falls back to a harness default, NOT inherit' `
'add "model: inherit" to the agent frontmatter (H8 all-inherit)'
}
elseif ($modelVal -cne 'inherit') {
Write-Flag 'MED' ("{0}:{1}" -f (Rel $a.FullName), $modelLine) `
("frontmatter pins 'model: {0}' - H8 says every agent inherits the lead" -f $modelVal) `
'set "model: inherit", or have the owner ratify the pin and record it in STATUS.md Sub-agents'
}
else {
$inheritCount++
}
}
# verdict COMPUTED from the counts, never a pasted label (W0.4 lesson 2)
Write-Host (" frontmatter: {0}/{1} agent .md carry 'model: inherit'" -f $inheritCount, $agentFiles.Count)
if ($agentFiles.Count -eq 0) {
Write-Host ' [NO-SCOPE] 0 agent .md found - a clean result here would be VACUOUS.' -ForegroundColor Yellow
}
elseif ($inheritCount -eq $agentFiles.Count) {
Write-Host (" [OK] all-inherit holds ({0}/{0})" -f $agentFiles.Count) -ForegroundColor Green
}
}
# ===========================================================================
# SECTION E - declared limits (printed EVERY run, so no report can over-read)
# ===========================================================================
Write-Section 'E - what this report does NOT say'
Write-Host ' 1. SPOT-CHECK BY CONVENTION, NOT ENFORCEMENT. Nothing here blocks a spawn (H23 section 2(4)).'
Write-Host ' 2. CURRENT SESSION ONLY. Older runs predate the convention; scanning them yields noise, not a bug storm.'
Write-Host ' 3. NOT "the floor is applied". This measures explicitness in transcripts, never documentation coverage.'
Write-Host ' 4. RETROSPECTIVE ONLY. It reads models that ALREADY resolved; it cannot pre-verify the NEXT spawn.'
Write-Host ' 5. NOT TESTED - spawn param vs a frontmatter HARD-PIN: SE has 0 hard-pinned agents (12/12 inherit),'
Write-Host ' so that precedence case has no evidence here. The inherit case IS measured (see header, wf_cb964f83-331).'
Write-Host ' 6. NOT TRIED - whether the spawn param rejects a full-id (fix #8a). Docs say enum alias; never attempted.'
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
Write-Section 'Summary'
Write-Host ("TOTAL FLAGS: {0}" -f $script:FlagCount) -ForegroundColor Cyan
Write-Host 'NOTE: informational audit. Exit 0 always (never fails a build). FLAGs are advisory - owner decides re-pin.' -ForegroundColor DarkGray
exit 0

View File

@ -0,0 +1,535 @@
<#
.SYNOPSIS
wal-recovery-test.ps1 - rerunnable regression TEST for FIX #3-bis (WAL fold branch rule).
.DESCRIPTION
Turns the hand-run W0.3 fault-injection (S120, run-folder W0-evidence 4.1/4.2) into a
test that can be re-run. This is a TEST, not a detector: it EXITS NON-ZERO on failure
(governance-detectors.ps1 is exit-0-always by mandate; this one is a gate for itself).
RULE UNDER TEST (FIX #3-bis, spec v3-PATCH):
K = git rev-list --count origin/main..HEAD
BOTTOM = git log --format=%s origin/main..HEAD | LAST line (= OLDEST commit in range)
K == 0 -> 'noop' empty range: no reset, no rebase, commit straight
BOTTOM starts wal: -> 'reset-soft' ca1 (NONWAL=0) AND ca2 (NONWAL=1) - SAME handling
else -> 'rebase-fixup' ca3 (bottom = ordinary commit)
NONWAL IS NOT A BRANCH VARIABLE. Select-WalFoldBranch does not even ACCEPT it as a
parameter - a structural anchor (asserted at runtime through Get-Command reflection,
NOT through a grep over source text: a grep would self-match this comment block).
WHY ca2 KILLS THE OLD RULE (spec v2 :92 branched on NONWAL==0):
shape base|wal:|[CLAUDE] has NONWAL=1 -> old rule picks REBASE -> todo head = pick wal:
-> fixup melts the top commit INTO the bottom one and INHERITS THE BOTTOM MESSAGE
-> range still contains a 'wal:' commit -> push-guard blocks -> re-squash -> same result
-> permanent STOP. The hazard is that it SUCCEEDS INCORRECTLY: exit 0, no rebase-merge
residue, no trace. So a test asserting only 'exit code == 0' would PASS on the bug.
ANTI-TAUTOLOGY ORACLE:
The expected branch is NOT a restatement of the rule. For every case BOTH candidate
branches are EXECUTED on independent throwaway trees and the post-state is MEASURED
(count of 'wal:' commits left in origin/main..HEAD after the fold + re-commit):
exactly one candidate CLEAN -> expectation is EMPIRICAL (measured, not asserted)
both candidates CLEAN -> oracle INCONCLUSIVE -> falls back to the spec-stated
expectation, and the table SAYS SO (honest label)
ca1/ca2 are EMPIRICAL. ca0/ca3 are DEFINITIONAL - declared, not hidden.
REGRESSION-GUARD (the real value): at ca2 the test asserts the SUT picks the branch this
very run MEASURED to be clean, and that the legacy rule picks the branch this very run
MEASURED to deadlock. Flip Select-WalFoldBranch back to NONWAL-branching and ca2 FAILS.
Verify the teeth with -InjectLegacyRule (must exit non-zero).
.PARAMETER RepoRoot
Repo root. Used as a CONTAINMENT ORACLE only: this test must never touch the real .git.
Containment is proven STRUCTURALLY - every path this script writes derives from -WorkDir,
and -WorkDir is asserted to sit outside RepoRoot. The RepoRoot HEAD + porcelain snapshot is
printed as corroboration but deliberately NOT asserted: in a concurrent multi-agent wave a
sibling lane or the WAL Stop-hook can legitimately move both mid-run, so asserting on them
would fail for exogenous reasons (a flaky gate is worse than no gate). No repo file is read
as test material - the trees are synthesized from scratch.
.PARAMETER WorkDir
Parent dir for the throwaway git trees. Default = $env:TEMP.
.PARAMETER KeepTemp
Keep the throwaway trees for inspection instead of deleting them.
.PARAMETER InjectLegacyRule
FAULT-INJECTION SELF-TEST. Swaps the SUT for the legacy NONWAL rule. Expected result:
ca2 FAILS and the script exits non-zero. Proves this test is not vacuously green.
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File scripts/wal-recovery-test.ps1
powershell.exe -ExecutionPolicy Bypass -File scripts/wal-recovery-test.ps1 -InjectLegacyRule
#>
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[string]$WorkDir = $env:TEMP,
[switch]$KeepTemp,
[switch]$InjectLegacyRule
)
$ErrorActionPreference = 'Continue'
# ---------------------------------------------------------------------------
# Result plumbing
# ---------------------------------------------------------------------------
$script:AssertCount = 0
$script:FailCount = 0
$script:CaseFail = @{}
# PRE-fold measurement per case, captured from real git BEFORE any surgery. The guard
# below must reuse THIS, never re-measure a main tree that has already been folded.
$script:CaseMeasure = @{}
$script:Rows = New-Object System.Collections.Generic.List[object]
function Write-Section($title) {
Write-Host ''
Write-Host ("===== $title =====") -ForegroundColor Cyan
}
function Assert-That {
param([string]$CaseId, [string]$Name, [bool]$Ok, [string]$Detail)
$script:AssertCount++
if ($Ok) {
Write-Host (" [PASS] {0,-4} | {1,-26} | {2}" -f $CaseId, $Name, $Detail) -ForegroundColor Green
}
else {
Write-Host (" [FAIL] {0,-4} | {1,-26} | {2}" -f $CaseId, $Name, $Detail) -ForegroundColor Red
$script:FailCount++
$script:CaseFail[$CaseId] = $true
}
}
# ---------------------------------------------------------------------------
# RULE UNDER TEST (SUT) - FIX #3-bis
# Branch variable = BOTTOM of range (+ K for the empty case). NONWAL absent BY DESIGN:
# once the bottom is 'wal:', reset --soft is correct for NONWAL=0 and NONWAL>0 alike,
# so NONWAL cannot change the handling and must not select the branch.
# ---------------------------------------------------------------------------
function Select-WalFoldBranch {
param([int]$K, [string]$Bottom)
if ($K -eq 0) { return 'noop' }
if ($Bottom -match '^wal:') { return 'reset-soft' }
return 'rebase-fixup'
}
# LEGACY rule (spec v2 :92). Kept ONLY as the regression oracle - never as the SUT
# unless -InjectLegacyRule is passed to prove this test has teeth.
function Select-WalFoldBranchLegacy {
param([int]$Nonwal)
if ($Nonwal -eq 0) { return 'reset-soft' }
return 'rebase-fixup'
}
function Get-FoldBranch {
param([int]$K, [string]$Bottom, [int]$Nonwal)
if ($InjectLegacyRule) { return (Select-WalFoldBranchLegacy -Nonwal $Nonwal) }
return (Select-WalFoldBranch -K $K -Bottom $Bottom)
}
# ---------------------------------------------------------------------------
# git helpers - every path derives from $WorkDir / $RepoRoot, none hardcoded.
# ---------------------------------------------------------------------------
$script:SeqEditor = $null
function New-SeqEditor {
param([string]$Dir)
# Sequence editor: keep the FIRST pick, turn every later pick into fixup.
# Written with LF + no BOM so the git todo parser sees clean lines.
$p = Join-Path $Dir 'wal-test-seq-editor.ps1'
$body = @(
'param($TodoPath)',
'$lines = Get-Content -LiteralPath $TodoPath',
'$seen = $false',
'$out = New-Object System.Collections.Generic.List[string]',
'foreach ($l in $lines) {',
' if ($l -match "^pick ") {',
' if ($seen) { $out.Add(($l -replace "^pick ", "fixup ")) }',
' else { $seen = $true; $out.Add($l) }',
' } else { $out.Add($l) }',
'}',
'[System.IO.File]::WriteAllText($TodoPath, (($out -join "`n") + "`n"))',
'exit 0'
) -join "`n"
[System.IO.File]::WriteAllText($p, $body)
return $p
}
function New-CaseTree {
param([string]$Path, [string[]]$Shape)
if (Test-Path $Path) { Remove-Item -Recurse -Force -LiteralPath $Path }
New-Item -ItemType Directory -Path $Path -Force | Out-Null
Push-Location $Path
git init -q .
git config user.email 'wal-test@local'
git config user.name 'wal-test'
git config commit.gpgsign false
git config core.autocrlf false
# Isolate from any inherited hook path: a stray hook must not edit these trees.
git config core.hooksPath (Join-Path $Path '.git/no-such-hooks')
[System.IO.File]::WriteAllText((Join-Path $Path 'base.txt'), "base`n")
git add -- base.txt
git commit -q -m '[CLAUDE] Infra: base'
# Fake origin/main as a LOCAL ref - no network, no real remote.
git update-ref refs/remotes/origin/main HEAD
$n = 0
foreach ($subject in $Shape) {
$n++
[System.IO.File]::WriteAllText((Join-Path $Path "f$n.txt"), "content $n`n")
git add -- "f$n.txt"
git commit -q -m $subject
}
Pop-Location
}
function Measure-Range {
param([string]$Path)
Push-Location $Path
$k = [int](git rev-list --count origin/main..HEAD)
$log = @(git log --format=%s origin/main..HEAD)
Pop-Location
$bottom = ''
if ($log.Count -gt 0) { $bottom = $log[-1] }
return [pscustomobject]@{
K = $k
Bottom = $bottom
Nonwal = @($log | Where-Object { -not ($_ -match '^wal:') }).Count
Nwal = @($log | Where-Object { $_ -match '^wal:' }).Count
Log = $log
}
}
function Get-RefSnapshot {
param([string]$Path)
Push-Location $Path
$s = @(git for-each-ref --format='%(refname) %(objectname)') -join '|'
$h = (git rev-parse HEAD)
Pop-Location
return ("HEAD=$h REFS=$s")
}
# Executes one fold branch on $Path. Returns what was MEASURED, asserts nothing.
function Invoke-FoldBranch {
param([string]$Path, [string]$Branch, [int]$K)
$r = [pscustomobject]@{
Branch = $Branch
RebaseInvoked = $false
RebaseExit = $null
ResetInvoked = $false
HeadKEqOrigin = $null
StateLeft = $false
Error = ''
}
Push-Location $Path
if ($Branch -eq 'noop') {
# ca0: deliberately do NOTHING. No reset, no rebase.
}
elseif ($Branch -eq 'reset-soft') {
# Safety assert BEFORE touching anything (v2 W0.3 gate).
$a = (git rev-parse "HEAD~$K")
$b = (git rev-parse origin/main)
$r.HeadKEqOrigin = ($a -eq $b)
if ($r.HeadKEqOrigin) {
$r.ResetInvoked = $true
git reset --soft "HEAD~$K" | Out-Null
git diff --cached --quiet
if ($LASTEXITCODE -ne 0) { git commit -q -m '[CLAUDE] Infra: fold re-commit' }
}
else {
$r.Error = 'ABORT: HEAD~K != origin/main'
}
}
elseif ($Branch -eq 'rebase-fixup') {
$env:GIT_SEQUENCE_EDITOR = ('powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $script:SeqEditor + '"')
# Hang-guard: never let git open an interactive message editor.
$env:GIT_EDITOR = 'powershell.exe -NoProfile -Command exit 0'
$r.RebaseInvoked = $true
git rebase -i origin/main 2>$null | Out-Null
$r.RebaseExit = $LASTEXITCODE
if ($r.RebaseExit -ne 0) { git rebase --abort 2>$null | Out-Null }
$env:GIT_SEQUENCE_EDITOR = $null
$env:GIT_EDITOR = $null
}
# Rebase-state residue check after EVERY step, on every branch.
$r.StateLeft = ((Test-Path '.git/rebase-merge') -or (Test-Path '.git/rebase-apply'))
Pop-Location
return $r
}
# Runs a candidate branch on a FRESH tree and reports the measured outcome.
function Test-Candidate {
param([string]$Root, [string]$Id, [string[]]$Shape, [string]$Branch)
$p = Join-Path $Root $Id
New-CaseTree -Path $p -Shape $Shape
$pre = Measure-Range -Path $p
$exec = Invoke-FoldBranch -Path $p -Branch $Branch -K $pre.K
$post = Measure-Range -Path $p
$outcome = 'CLEAN'
if ($post.Nwal -gt 0) { $outcome = 'DEADLOCK' }
return [pscustomobject]@{
Branch = $Branch; Exec = $exec; Post = $post; Outcome = $outcome; Path = $p
}
}
# ---------------------------------------------------------------------------
# Bootstrap + containment
# ---------------------------------------------------------------------------
Write-Section 'setup + containment (RepoRoot must stay untouched)'
$stamp = (Get-Date -Format 'yyyyMMddTHHmmss')
$root = Join-Path $WorkDir ("wal-recovery-test-" + $stamp)
New-Item -ItemType Directory -Path $root -Force | Out-Null
$script:SeqEditor = New-SeqEditor -Dir $root
Write-Host (" RepoRoot = {0}" -f $RepoRoot)
Write-Host (" WorkDir = {0}" -f $root)
if ($InjectLegacyRule) {
Write-Host ' MODE = FAULT-INJECT (-InjectLegacyRule): SUT swapped to legacy NONWAL rule.' -ForegroundColor Magenta
Write-Host ' EXPECTED RESULT = ca2 FAIL + non-zero exit.' -ForegroundColor Magenta
}
# Containment (a) HARD ASSERT: every tree this script builds hangs off $root, so proving
# $root is outside RepoRoot proves the real .git is unreachable. Structural, not flaky.
$rrFull = ($RepoRoot -replace '/', '\').TrimEnd('\')
$wdFull = ($root -replace '/', '\')
Assert-That 'env' 'workdir-outside-repo' (-not $wdFull.StartsWith(($rrFull + '\'), [StringComparison]::OrdinalIgnoreCase)) `
("WorkDir not under RepoRoot -> real .git cannot be reached")
# Containment (b) INFO ONLY: snapshot the real repo, re-printed at the end. NOT asserted -
# see .PARAMETER RepoRoot: concurrent lanes / the WAL Stop-hook move these legitimately.
$repoIsGit = Test-Path (Join-Path $RepoRoot '.git')
$repoHeadBefore = 'n/a'
$repoDirtyBefore = -1
if ($repoIsGit) {
$repoHeadBefore = (git -C $RepoRoot rev-parse HEAD)
$repoDirtyBefore = @(git -C $RepoRoot status --porcelain).Count
Write-Host (" RepoRoot HEAD before = {0} ; porcelain entries = {1}" -f $repoHeadBefore, $repoDirtyBefore)
}
else {
Write-Host ' [note] RepoRoot is not a git repo - containment (b) reduced to path check only' -ForegroundColor DarkGray
}
# Structural anchor: the SUT must not be ABLE to branch on NONWAL.
$sutParams = @((Get-Command Select-WalFoldBranch).Parameters.Keys)
$hasNonwal = $false
foreach ($pn in $sutParams) { if ($pn -match '(?i)nonwal') { $hasNonwal = $true } }
Assert-That 'env' 'sut-has-no-NONWAL-param' (-not $hasNonwal) `
("Select-WalFoldBranch params = [{0}] (structural, via Get-Command - not a source grep)" -f ($sutParams -join ', '))
# ---------------------------------------------------------------------------
# The 4 cases. Shape = commits laid on top of base, BOTTOM-FIRST.
# ---------------------------------------------------------------------------
$WAL1 = 'wal: flush 1'
$WAL2 = 'wal: flush 2'
$REAL = '[CLAUDE] Infra: real work'
$cases = @(
[pscustomobject]@{ Id = 'ca0'; Shape = @(); Label = 'base'; SpecBranch = 'noop' },
[pscustomobject]@{ Id = 'ca1'; Shape = @($WAL1, $WAL2); Label = 'base|wal:|wal:'; SpecBranch = 'reset-soft' },
[pscustomobject]@{ Id = 'ca2'; Shape = @($WAL1, $REAL); Label = 'base|wal:|[CLAUDE]'; SpecBranch = 'reset-soft' },
[pscustomobject]@{ Id = 'ca3'; Shape = @($REAL, $WAL1); Label = 'base|[CLAUDE]|wal:'; SpecBranch = 'rebase-fixup' }
)
foreach ($c in $cases) {
$script:CaseFail[$c.Id] = $false
Write-Section ("{0} : {1}" -f $c.Id, $c.Label)
# --- 1. build the tree and MEASURE the branch inputs from real git ---
$main = Join-Path $root ($c.Id + '-main')
New-CaseTree -Path $main -Shape $c.Shape
$m = Measure-Range -Path $main
$script:CaseMeasure[$c.Id] = $m
Write-Host (" measured: K={0} NONWAL={1} BOTTOM=[{2}]" -f $m.K, $m.Nonwal, $m.Bottom)
foreach ($l in $m.Log) { Write-Host (" range: {0}" -f $l) -ForegroundColor DarkGray }
$sut = Get-FoldBranch -K $m.K -Bottom $m.Bottom -Nonwal $m.Nonwal
$legacy = Select-WalFoldBranchLegacy -Nonwal $m.Nonwal
Write-Host (" SUT picks = {0}" -f $sut)
Write-Host (" legacy rule = {0} (branches on NONWAL - spec v2 :92)" -f $legacy)
# --- 2. ORACLE: execute BOTH candidates on independent trees, measure outcome ---
$cfReset = Test-Candidate -Root $root -Id ($c.Id + '-cf-reset') -Shape $c.Shape -Branch 'reset-soft'
$cfRebase = Test-Candidate -Root $root -Id ($c.Id + '-cf-rebase') -Shape $c.Shape -Branch 'rebase-fixup'
Write-Host (" counterfactual reset-soft -> n(wal: in range)={0} -> {1}" -f $cfReset.Post.Nwal, $cfReset.Outcome)
Write-Host (" counterfactual rebase-fixup -> n(wal: in range)={0} -> {1} [rebase-exit={2}]" -f `
$cfRebase.Post.Nwal, $cfRebase.Outcome, $cfRebase.Exec.RebaseExit)
$clean = @()
if ($cfReset.Outcome -eq 'CLEAN') { $clean += 'reset-soft' }
if ($cfRebase.Outcome -eq 'CLEAN') { $clean += 'rebase-fixup' }
$oracleKind = 'DEFINITIONAL'
$expected = $c.SpecBranch
if ($c.Id -ne 'ca0' -and $clean.Count -eq 1) {
$oracleKind = 'EMPIRICAL'
$expected = $clean[0]
}
if ($oracleKind -eq 'EMPIRICAL') {
Write-Host (" ORACLE = EMPIRICAL: exactly one candidate survives the push-guard -> expected={0}" -f $expected) -ForegroundColor Yellow
}
else {
Write-Host (" ORACLE = DEFINITIONAL: both candidates outcome-equivalent on the n(wal:) metric") -ForegroundColor DarkGray
Write-Host (" -> expected={0} taken from spec v3-PATCH, NOT measured. Declared, not hidden." -f $expected) -ForegroundColor DarkGray
}
# --- 3. execute the SUT-chosen branch on the main tree ---
$snapBefore = Get-RefSnapshot -Path $main
$exec = Invoke-FoldBranch -Path $main -Branch $sut -K $m.K
$snapAfter = Get-RefSnapshot -Path $main
$post = Measure-Range -Path $main
# --- 4. asserts ---
Assert-That $c.Id 'branch-selection' ($sut -eq $expected) `
("SUT={0} expected={1} ({2} oracle)" -f $sut, $expected, $oracleKind)
Assert-That $c.Id 'no-rebase-state-left' (-not $exec.StateLeft) `
('.git/rebase-merge + .git/rebase-apply ABSENT after the step')
Assert-That $c.Id 'cf-no-rebase-state' ((-not $cfReset.Exec.StateLeft) -and (-not $cfRebase.Exec.StateLeft)) `
('.git/rebase-merge ABSENT on BOTH counterfactual trees too')
# REBASE-EXIT is never non-zero - on the main tree AND on the counterfactual that rebased.
$rebaseExits = @()
if ($null -ne $exec.RebaseExit) { $rebaseExits += $exec.RebaseExit }
if ($null -ne $cfRebase.Exec.RebaseExit) { $rebaseExits += $cfRebase.Exec.RebaseExit }
$badExit = @($rebaseExits | Where-Object { $_ -ne 0 }).Count
Assert-That $c.Id 'rebase-exit-never-nonzero' ($badExit -eq 0) `
("rebase exits observed = [{0}] ; non-zero count = {1}" -f (($rebaseExits | ForEach-Object { "$_" }) -join ','), $badExit)
if ($sut -eq 'reset-soft') {
Assert-That $c.Id 'headK-eq-origin-pre-reset' ($exec.HeadKEqOrigin -eq $true) `
("HEAD~K == origin/main asserted BEFORE reset (K={0})" -f $m.K)
}
if ($c.Id -eq 'ca0') {
Assert-That $c.Id 'noop-touched-nothing' ((-not $exec.ResetInvoked) -and (-not $exec.RebaseInvoked)) `
('no reset, no rebase issued on an empty range')
Assert-That $c.Id 'git-tree-identical' ($snapBefore -eq $snapAfter) `
('.git ref-state byte-identical before/after')
}
if ($c.Id -eq 'ca2') {
# v3-PATCH acceptance: ca2 must not touch git rebase AT ALL.
Assert-That $c.Id 'ca2-never-rebases' (-not $exec.RebaseInvoked) `
('git rebase NOT invoked on ca2')
}
if ($oracleKind -eq 'EMPIRICAL') {
Assert-That $c.Id 'post-state-clean' ($post.Nwal -eq 0) `
("n(wal: in origin/main..HEAD) after SUT branch = {0}" -f $post.Nwal)
}
$verdict = 'PASS'
if ($script:CaseFail[$c.Id]) { $verdict = 'FAIL' }
$script:Rows.Add([pscustomobject]@{
Case = $c.Id; Shape = $c.Label; K = $m.K; Nonwal = $m.Nonwal
Sut = $sut; Legacy = $legacy; Expected = $expected; Oracle = $oracleKind; Verdict = $verdict
})
}
# ---------------------------------------------------------------------------
# REGRESSION-GUARD - the actual value of this script.
# Not a restatement of the rule: both claims below are MEASURED this run.
# ---------------------------------------------------------------------------
Write-Section 'REGRESSION-GUARD (ca2) - legacy NONWAL rule must stay rejected'
$c2 = $cases | Where-Object { $_.Id -eq 'ca2' }
$g1 = Test-Candidate -Root $root -Id 'guard-ca2-legacy' -Shape $c2.Shape -Branch 'rebase-fixup'
# PRE-fold inputs of ca2, captured from real git in the loop above. Re-measuring
# ca2-main here would read the ALREADY-FOLDED tree (K=1, bottom = the re-commit) and
# silently ask the rule a different question than the one under test.
$m2 = $script:CaseMeasure['ca2']
Write-Host ' Replay of W0.3 4.2 - legacy branch (rebase+fixup) executed on a ca2 tree:'
Write-Host (" rebase-exit = {0} (0 = it SUCCEEDS, which is exactly the hazard)" -f $g1.Exec.RebaseExit)
Write-Host (" rebase-state left = {0}" -f $g1.Exec.StateLeft)
foreach ($l in $g1.Post.Log) { Write-Host (" after: {0}" -f $l) -ForegroundColor DarkGray }
Write-Host (" n(wal: in range) = {0}" -f $g1.Post.Nwal)
# Fixed-point probe: re-folding the deadlocked range never escapes it.
$g2 = $null
if ($g1.Post.Nwal -gt 0) {
$g2 = Invoke-FoldBranch -Path $g1.Path -Branch 'rebase-fixup' -K $g1.Post.K
$after2 = Measure-Range -Path $g1.Path
Write-Host (" re-squash (2nd fold) -> n(wal:)={0} [rebase-exit={1}] -> fixed point = PERMANENT STOP" -f `
$after2.Nwal, $g2.RebaseExit)
Assert-That 'grd' 'deadlock-is-fixed-point' ($after2.Nwal -gt 0) `
('re-squashing the legacy result still leaves a wal: commit -> push-guard blocks forever')
}
Assert-That 'grd' 'legacy-branch-deadlocks' (($g1.Exec.RebaseExit -eq 0) -and ($g1.Post.Nwal -gt 0)) `
("MEASURED: legacy branch exits 0 yet leaves n(wal:)={0} -> succeeds INCORRECTLY" -f $g1.Post.Nwal)
$legacyCa2 = Select-WalFoldBranchLegacy -Nonwal $m2.Nonwal
$sutCa2 = Get-FoldBranch -K $m2.K -Bottom $m2.Bottom -Nonwal $m2.Nonwal
Assert-That 'grd' 'legacy-picks-the-bad-one' ($legacyCa2 -eq 'rebase-fixup') `
("legacy(NONWAL={0}) = {1} = the branch measured to deadlock" -f $m2.Nonwal, $legacyCa2)
Assert-That 'grd' 'sut-rejects-legacy-branch' ($sutCa2 -ne 'rebase-fixup') `
("SUT = {0} != the measured-deadlock branch. Revert the rule to NONWAL and THIS assert fails." -f $sutCa2)
Assert-That 'grd' 'rules-diverge-at-ca2' ($sutCa2 -ne $legacyCa2) `
("SUT={0} vs legacy={1} - divergence is what makes this guard non-vacuous" -f $sutCa2, $legacyCa2)
# ---------------------------------------------------------------------------
# Result table
# ---------------------------------------------------------------------------
Write-Section 'RESULT'
Write-Host ('{0,-5} {1,-22} {2,-3} {3,-7} {4,-13} {5,-13} {6,-13} {7,-13} {8}' -f `
'CASE', 'SHAPE', 'K', 'NONWAL', 'SUT', 'LEGACY', 'EXPECTED', 'ORACLE', 'VERDICT')
Write-Host ('-' * 118)
foreach ($r in $script:Rows) {
$col = 'Green'
if ($r.Verdict -eq 'FAIL') { $col = 'Red' }
Write-Host ('{0,-5} {1,-22} {2,-3} {3,-7} {4,-13} {5,-13} {6,-13} {7,-13} {8}' -f `
$r.Case, $r.Shape, $r.K, $r.Nonwal, $r.Sut, $r.Legacy, $r.Expected, $r.Oracle, $r.Verdict) -ForegroundColor $col
}
$passCases = @($script:Rows | Where-Object { $_.Verdict -eq 'PASS' }).Count
$totalCases = $script:Rows.Count
# Containment (b) re-check - INFO, never a verdict input (concurrency-exogenous).
Write-Host ''
if ($repoIsGit) {
$repoHeadAfter = (git -C $RepoRoot rev-parse HEAD)
$repoDirtyAfter = @(git -C $RepoRoot status --porcelain).Count
$same = (($repoHeadAfter -eq $repoHeadBefore) -and ($repoDirtyAfter -eq $repoDirtyBefore))
Write-Host (" [info] RepoRoot HEAD {0} -> {1} ; porcelain {2} -> {3} ; unchanged={4}" -f `
$repoHeadBefore.Substring(0, 7), $repoHeadAfter.Substring(0, 7), $repoDirtyBefore, $repoDirtyAfter, $same)
if (-not $same) {
Write-Host ' [info] delta above is NOT a failure: containment is proven by workdir-outside-repo.' -ForegroundColor DarkGray
Write-Host ' A concurrent lane or the WAL Stop-hook can move HEAD/porcelain mid-run.' -ForegroundColor DarkGray
}
}
# ---------------------------------------------------------------------------
# Cleanup + exit. pass/fail COMPUTED from the counters - never a pasted label.
# ---------------------------------------------------------------------------
if ($KeepTemp) {
Write-Host ("temp trees KEPT at: {0}" -f $root) -ForegroundColor Yellow
}
else {
Set-Location $WorkDir
Remove-Item -Recurse -Force -LiteralPath $root -ErrorAction SilentlyContinue
Write-Host ("temp trees removed ({0}) - pass -KeepTemp to inspect" -f $root) -ForegroundColor DarkGray
}
Write-Host ''
Write-Host ("cases {0}/{1} PASS | asserts {2} run, {3} failed" -f $passCases, $totalCases, $script:AssertCount, $script:FailCount) -ForegroundColor Cyan
$exitCode = 0
if ($script:FailCount -gt 0) { $exitCode = 1 }
if ($passCases -ne $totalCases) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host 'VERDICT: PASS' -ForegroundColor Green
}
else {
Write-Host 'VERDICT: FAIL' -ForegroundColor Red
if ($InjectLegacyRule) {
Write-Host 'NOTE: -InjectLegacyRule was set. A FAIL here is the EXPECTED result: it proves the test has teeth.' -ForegroundColor Magenta
}
}
exit $exitCode