diff --git a/scripts/agent-frontmatter-eol-check.ps1 b/scripts/agent-frontmatter-eol-check.ps1 new file mode 100644 index 0000000..121c70b --- /dev/null +++ b/scripts/agent-frontmatter-eol-check.ps1 @@ -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 + git checkout -- , 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 diff --git a/scripts/governance-detectors.ps1 b/scripts/governance-detectors.ps1 index f41ac14..1b78f9c 100644 --- a/scripts/governance-detectors.ps1 +++ b/scripts/governance-detectors.ps1 @@ -19,6 +19,11 @@ C3 - vocab-fork : alias-sets where >=2 variants live side-by-side. C4 - self-line exclusion: pattern-describing files removed from every scan (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:] keys alive across >= M consecutive + most-recent carry-lines. M read from config, never + hardcoded. INFORM-only. Each FLAG line: [DETECTOR] severity | file:line | description | resolve: (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_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) # These files DESCRIBE the patterns the detectors look for; without exclusion @@ -151,13 +162,37 @@ if (-not (Test-Path $statusPath)) { $canonicalOk = $false } else { - $canonical['mig'] = Get-StatusValue $statusPath 'Migrations' - $canonical['test'] = Get-StatusValue $statusPath 'Tests' - $canonical['gotcha'] = Get-StatusValue $statusPath 'Gotchas' - $canonical['table'] = Get-StatusValue $statusPath 'SQL tables' + # GAP-3 (H24): canonical token -> STATUS.md CURRENT-STATE row label. + # Row labels are READ OFF DISK, not guessed: 'Menu keys' is docs/STATUS.md:19 + # ("| Menu keys | **54** |"); 'Policies' does NOT exist yet -- W2 lands + # "| 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 ` - $canonical['mig'], $canonical['test'], $canonical['gotcha'], $canonical['table']) + $canonShow = @() + 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} | **** |' 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 ---- # 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*test'; Key = 'test'; Label = 'test' }, @{ 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 @@ -427,6 +467,226 @@ if (Test-Path $walPath) { 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 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:] 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:]" (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:] 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) # --------------------------------------------------------------------------- diff --git a/scripts/spawn-model-audit.ps1 b/scripts/spawn-model-audit.ps1 new file mode 100644 index 0000000..810c96e --- /dev/null +++ b/scripts/spawn-model-audit.ps1 @@ -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 + ///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 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 diff --git a/scripts/wal-recovery-test.ps1 b/scripts/wal-recovery-test.ps1 new file mode 100644 index 0000000..c9e0dd7 --- /dev/null +++ b/scripts/wal-recovery-test.ps1 @@ -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