# memory-archive-gate.ps1 - Harness-11 PART-A (S73, 2026-06-18) # Mechanized standing-gate for agent-memory hot-tier (L1 MEMORY.md). # # NON-NEGOTIABLES (Harness-11): # (1) NO-API : grep/Select-String + byte/file-exist ONLY. NEVER calls a model. # (2) FLAG-ONLY: DRY-RUN by default. Prints a PLAN + FLAGS. Does NOT move/edit # any MEMORY.md or archive file. (auto-WRITE of rules = top hazard.) # (3) PS 5.1 : ASCII-only output (gotcha #30). powershell.exe -ExecutionPolicy Bypass -File # # WHAT IT DOES (two independent passes): # PLANNER (A1/A4/A5/A6) : for each /MEMORY.md, measure bytes; if over cap, # plan how many oldest entries to MOVE to get BELOW the # low-watermark (hysteresis), never draining below a # keep-floor of newest entries; gate the *proposal* behind # a 2-strike counter persisted to .archive-strikes.json. # A7 L1-GATE (NO-API) : for each /archive/_INDEX.md, verify every # substring:"..." pointer resolves (SimpleMatch) inside the # sub's archive/*.md, and that referenced archive files # exist + size>0. Prints PASS/FAIL. # # TAILORING NOTE (Harness-11 PART-A = 'tailorable'): see header comment block at the # bottom marked [TAILOR] for the simplifications made vs the maximal spec. # # Usage: # powershell.exe -ExecutionPolicy Bypass -File scripts\memory-archive-gate.ps1 # powershell.exe -ExecutionPolicy Bypass -File scripts\memory-archive-gate.ps1 -Apply (records strikes; STILL no file moves) param( [string]$RepoRoot = "$PSScriptRoot\..", [switch]$Apply = $false ) $ErrorActionPreference = 'Stop' # ---- resolve paths -------------------------------------------------------- $memRoot = Join-Path $RepoRoot '.claude\agent-memory' $budgetPath = Join-Path $memRoot 'memory-budget.json' $strikePath = Join-Path $memRoot '.archive-strikes.json' if (-not (Test-Path $memRoot)) { Write-Error "agent-memory root not found: $memRoot"; exit 1 } if (-not (Test-Path $budgetPath)){ Write-Error "memory-budget.json not found: $budgetPath"; exit 1 } # ---- load tunables from budget.json (archive_gate block) ------------------ $budget = Get-Content $budgetPath -Raw | ConvertFrom-Json $gate = $budget.archive_gate if ($null -eq $gate) { Write-Error "memory-budget.json missing 'archive_gate' block (Harness-11 PART-A)"; exit 1 } $cap = [int]$gate.autoinject_cap_bytes # A1: over this => over-cap $lowMark = [int]([math]::Floor($cap * [double]$gate.low_watermark_ratio)) # A4: drain target $keepFloor = [int]$gate.keep_floor_entries # A5: never auto-drain below N newest $strikeNeed = [int]$gate.strike_threshold # A6: consecutive over-cap runs before proposing # ---- strike-counter state (A6) ------------------------------------------- # Stateless script => persist a tiny counter file (additive, NOT a memory file). # Only mutated under -Apply so DRY-RUN is side-effect-free. $strikes = @{} if (Test-Path $strikePath) { try { $raw = Get-Content $strikePath -Raw | ConvertFrom-Json foreach ($p in $raw.PSObject.Properties) { $strikes[$p.Name] = [int]$p.Value } } catch { $strikes = @{} } } # ---- helpers -------------------------------------------------------------- # Entry boundaries in a hot MEMORY.md = lines matching one of: # ^## (h2) | ^### (h3) | ^--- (separator) # Count of such markers approximates entry count (A5 keep-floor uses this). function Get-EntryMarkerLineNumbers([string[]]$lines) { $idx = @() for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^(#{2,3}\s|---\s*$)') { $idx += $i } } return ,$idx } # ---- header --------------------------------------------------------------- $mode = if ($Apply) { "APPLY (records strikes; NO file moves)" } else { "DRY-RUN (no writes at all)" } Write-Output "============================================================" Write-Output " memory-archive-gate.ps1 - Harness-11 PART-A" Write-Output " mode : $mode" Write-Output " cap : $cap bytes (autoinject_cap)" Write-Output " low-water : $lowMark bytes (A4 hysteresis drain target = ratio $($gate.low_watermark_ratio))" Write-Output " keep-floor : $keepFloor newest entries (A5)" Write-Output " strike-need : $strikeNeed consecutive over-cap runs to PROPOSE (A6)" Write-Output "============================================================" # ========================================================================== # PASS 1 - PLANNER (A1 measure / A4 hysteresis / A5 keep-floor / A6 strike) # ========================================================================== Write-Output "" Write-Output "### PASS 1 - hot-tier over-cap planner (FLAG ONLY, no moves)" Write-Output "" $dash = [string]([char]45) # '-' as a value, never a bare token (PS 5.1 treats '--' runs as decrement op) Write-Output ("{0,-24} {1,9} {2,5} {3,10} {4,7} {5,12} {6}" -f 'sub','bytes','over?','entries','strike','after-est','resolve') Write-Output ("{0,-24} {1,9} {2,5} {3,10} {4,7} {5,12} {6}" -f ($dash*24),($dash*9),($dash*5),($dash*10),($dash*7),($dash*12),($dash*7)) $subDirs = Get-ChildItem -Path $memRoot -Directory | Sort-Object Name $anyOver = $false foreach ($d in $subDirs) { $sub = $d.Name $mem = Join-Path $d.FullName 'MEMORY.md' if (-not (Test-Path $mem)) { continue } $bytes = (Get-Item $mem).Length # A1 $isOver = $bytes -gt $cap $lines = Get-Content $mem $markers = Get-EntryMarkerLineNumbers $lines $entryCount = $markers.Count # --- A6 strike bookkeeping --- $prev = if ($strikes.ContainsKey($sub)) { [int]$strikes[$sub] } else { 0 } if ($isOver) { $cur = $prev + 1 } else { $cur = 0 # reset on a clean run (consecutive-only) } if ($Apply) { $strikes[$sub] = $cur } if (-not $isOver) { # Under cap: one tidy line, nothing to plan. Write-Output ("{0,-24} {1,9} {2,5} {3,10} {4,7} {5,12} {6}" -f $sub, $bytes, 'no', $entryCount, $cur, '-', 'ok') continue } $anyOver = $true # --- A4 hysteresis + A5 keep-floor : how many OLDEST entries to move? --- # Move oldest entries one-by-one; estimate bytes-after by cutting at the # marker line of the FIRST entry we keep. Stop when est < lowMark, but never # let kept-entries drop below keepFloor. $moveCount = 0 $afterEst = $bytes $warnFloor = $false if ($entryCount -le $keepFloor) { # Already at/under floor but still over cap => cannot auto-drain. $warnFloor = $true $afterEst = $bytes } else { # markers[k] = line index where entry (k) starts. Keeping entries # [k..end] means the kept region begins at byte offset of markers[k]. # Bytes-after = total - (bytes before markers[k]). for ($move = 1; $move -le ($entryCount - $keepFloor); $move++) { $cutLine = $markers[$move] # first KEPT entry starts here (0-based line idx) # bytes of the moved prefix = sum of (line length + 1 newline) for lines [0..cutLine-1] $prefixBytes = 0 for ($li = 0; $li -lt $cutLine; $li++) { $prefixBytes += ($lines[$li].Length + 2) } # +2 ~ CRLF est $est = $bytes - $prefixBytes $moveCount = $move $afterEst = $est if ($est -lt $lowMark) { break } } # If we exhausted the movable range and still >= lowMark, floor was hit. if ($afterEst -ge $cap -and $moveCount -eq ($entryCount - $keepFloor)) { $warnFloor = $true } } # --- A6 gate the resolution wording on the strike count --- if ($warnFloor) { $resolve = "WARN keep-floor hit ($keepFloor); cannot auto-drain - SPLIT/condense entries by hand" } elseif ($cur -ge $strikeNeed) { $resolve = "PROPOSE archive (strike $cur>=$strikeNeed): move $moveCount oldest -> curate L1->L2 by hand" } else { $resolve = "WATCH (strike $cur<$strikeNeed): re-run; propose only after $strikeNeed consecutive over-cap" } Write-Output ("{0,-24} {1,9} {2,5} {3,10} {4,7} {5,12} {6}" -f $sub, $bytes, 'YES', $entryCount, $cur, "~$afterEst", $resolve) } if (-not $anyOver) { Write-Output "" Write-Output " (no sub over cap - hot tier within auto-inject budget)" } # Persist strikes under -Apply (additive counter file, NOT a memory file). if ($Apply) { ($strikes | ConvertTo-Json) | Set-Content -Path $strikePath -Encoding ASCII Write-Output "" Write-Output " [A6] strikes persisted -> $strikePath" } else { Write-Output "" Write-Output " [A6] DRY-RUN: strike counters NOT persisted (run with -Apply to advance strikes)" } # ========================================================================== # PASS 2 - A7 NO-API L1-GATE : pointer-resolve + byte-sanity on EXISTING archive # ========================================================================== Write-Output "" Write-Output "### PASS 2 - A7 archive-integrity gate (NO-API: grep + measure only)" Write-Output "" $gateTotalPtr = 0 $gateOkPtr = 0 $gateFailPtr = 0 $anyArchive = $false foreach ($d in $subDirs) { $sub = $d.Name $archDir = Join-Path $d.FullName 'archive' $indexPath = Join-Path $archDir '_INDEX.md' if (-not (Test-Path $indexPath)) { continue } # only subs with a built index $anyArchive = $true # all archive content files (exclude the index itself) $contentFiles = Get-ChildItem -Path $archDir -Filter *.md | Where-Object { $_.Name -ne '_INDEX.md' } Write-Output " [$sub] _INDEX.md + $($contentFiles.Count) archive file(s)" # (ii) byte-sanity: every archive content file exists + size>0 foreach ($cf in $contentFiles) { if ($cf.Length -le 0) { Write-Output (" BYTE-FAIL {0} is 0 bytes" -f $cf.Name) $gateFailPtr++ } } # Pre-load every archive content file as UTF-8 (gotcha #30: PS 5.1 Get-Content # defaults to ANSI codepage and MANGLES Vietnamese diacritics / em-dash / arrows, # which made byte-identical pointers falsely FAIL. Force UTF-8 on BOTH sides.) $utf8 = New-Object System.Text.UTF8Encoding($false) $haystacks = @{} foreach ($cf in $contentFiles) { $haystacks[$cf.Name] = [System.IO.File]::ReadAllText($cf.FullName, $utf8) } # (i) pointer-resolve: extract every substring token (substring:QUOTE...QUOTE) and # locate it literally (String.Contains = SimpleMatch) in ANY archive file. $indexText = [System.IO.File]::ReadAllText($indexPath, $utf8) $indexLines = $indexText -split "`r?`n" $subPtrCount = 0; $subOk = 0; $subFail = 0 foreach ($line in $indexLines) { # skip blockquote legend/convention lines (e.g. the '> Pointer style ... # substring:""' template); those document the format, they # are not real record pointers. if ($line -match '^\s*>') { continue } # robust across all 3 formats (bullet / table / arrow) - just grab the quoted payload $m = [regex]::Matches($line, 'substring:"([^"]+)"') foreach ($match in $m) { $needle = $match.Groups[1].Value $subPtrCount++; $gateTotalPtr++ # literal substring search across ALL archive content files for this sub $found = $false foreach ($k in $haystacks.Keys) { if ($haystacks[$k].Contains($needle)) { $found = $true; break } } if ($found) { $subOk++; $gateOkPtr++ } else { $subFail++; $gateFailPtr++ $q = [char]34 Write-Output (" PTR-FAIL substring not found in archive/*.md : {0}{1}{0}" -f $q, $needle) } } } $verdict = if ($subFail -eq 0) { "PASS" } else { "FAIL" } Write-Output (" -> {0} pointers {1} resolved {2} failed {3}" -f $verdict, $subPtrCount, $subOk, $subFail) } if (-not $anyArchive) { Write-Output " (no sub has archive/_INDEX.md yet - nothing to gate)" } Write-Output "" Write-Output "------------------------------------------------------------" $overallA7 = if ($gateFailPtr -eq 0) { "PASS" } else { "FAIL" } Write-Output (" A7 GATE {0} - total pointers {1}, resolved {2}, failed {3}" -f $overallA7, $gateTotalPtr, $gateOkPtr, $gateFailPtr) Write-Output "------------------------------------------------------------" # Exit non-zero only on A7 integrity failure (broken pointer / 0-byte archive). # Over-cap is a FLAG (not an error) - the gate reports, a human curates. if ($gateFailPtr -gt 0) { exit 2 } else { exit 0 } # ========================================================================== # [TAILOR] Harness-11 PART-A simplifications (honest record): # * bytes-after-est uses (line.Length + 2) as a CRLF-aware estimate per moved # line; it is an ESTIMATE for the plan, not a real cut. The "~" prefix in the # after-est column flags it as approximate. (Real bytes only known post-move, # which the gate deliberately never performs.) # * Entry boundary = first of (^##, ^###, ^---). MEMORY.md files here are h2-only # today (verified S73), so marker-count == entry-count in practice; the regex # also tolerates h3/HR-delimited files. # * A6 strike state is a flat {sub: int} JSON. Reset-to-0 on any clean run => # "consecutive over-cap" semantics. Requires 2 real runs (-Apply) to reach the # PROPOSE nudge by design (the spec "runtime needs 2 runs" note). # * A7 resolves the substring against ALL archive/*.md for the sub (not just the # arrow-named file) because the 3 _INDEX formats name the target differently # (reviewer arrow / cicd arrow-then-substr / inv-codebase q-shorthand table). # A unique substring landing anywhere in the sub's frozen archive == resolved. # ==========================================================================