[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

@ -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:<slug>] 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: <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_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} | **<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 ----
# 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 <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)
# ---------------------------------------------------------------------------