# 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 <sub>/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 <sub>/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

# value-protect patterns (Harness-15 B(b), S81): HIGH-VALUE markers (recurring-bug /
# anti-pattern / gotcha / root-cause). If a planned MOVE would archive one OUT of
# L1-hot regardless of age, FLAG it (keep-by-VALUE, not FIFO-by-date). ADVISORY FLAG
# ONLY - em-main decides (no auto-exclude); keep_floor stays the recency axis.
$valPatterns = @()
if ($gate.value_protect -and $gate.value_protect.patterns) {
    foreach ($vp in $gate.value_protect.patterns) { if ($vp) { $valPatterns += [string]$vp } }
}

# ---- 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

    # --- D3 (DIRECTED 6c32df89 REC-3, 2026-07-13): value-PRIMARY drain (was A4/A5 oldest-first) ---
    # CORRECTED per fable-real M2: value-protected LOGICAL entries are HARD-SKIPPED (partitioned
    # OUT of the movable pool) BEFORE the size-drain, then bytes are accumulated over the chosen
    # INDIVIDUAL (non-contiguous) low-value entries -- NOT a contiguous top-prefix (a naive
    # "skip" on the old prefix-cut left the protected entry ABOVE the cut = silent inversion).
    # value_protect is now a PRE-SELECTION hard-skip, not an advisory post-hoc flag.
    # LOGICAL entries = HEADING markers ONLY (^#{2,3}\s) -- NOT the '---' separators that
    # Get-EntryMarkerLineNumbers also counts (else a value token after a '---' flags the wrong
    # pseudo-span). keep_floor / DRY-RUN / strike-gating all preserved.
    $moveCount = 0
    $afterEst = $bytes
    $warnFloor = $false
    $warnValue = $false

    # (1) logical-entry heads + per-entry byte size + value-protected flag (whole span, age-blind)
    $headIdx = @()
    for ($hi = 0; $hi -lt $lines.Count; $hi++) { if ($lines[$hi] -match '^#{2,3}\s') { $headIdx += $hi } }
    $logCount = $headIdx.Count
    $entBytes = @()
    $entProt  = @()
    for ($k = 0; $k -lt $logCount; $k++) {
        $start = $headIdx[$k]
        if ($k -lt $logCount - 1) { $end = $headIdx[$k + 1] - 1 } else { $end = $lines.Count - 1 }
        $b = 0
        $p = $false
        for ($li = $start; $li -le $end; $li++) {
            $b += ($lines[$li].Length + 2)   # +2 ~ CRLF est (mirror legacy)
            if ((-not $p) -and ($valPatterns.Count -gt 0)) {
                foreach ($vp in $valPatterns) { if ($lines[$li] -like "*$vp*") { $p = $true; break } }
            }
        }
        $entBytes += $b
        $entProt  += $p
    }

    if ($logCount -le $keepFloor) {
        # At/under keep-floor but still over cap => cannot auto-drain by size.
        $warnFloor = $true
        $afterEst = $bytes
    } else {
        # (2) keep_floor = newest-N logical entries (bottom = newest, append-to-end convention)
        $floorStart = $logCount - $keepFloor
        # (3)+(4) NON-CONTIGUOUS size-drain: position-order walk BUT hard-skip value-protected;
        #         accumulate INDIVIDUAL chosen low-value bytes (never a top-prefix).
        $movedBytes = 0
        for ($k = 0; $k -lt $floorStart; $k++) {
            if ($entProt[$k]) { continue }   # value-primary hard-skip (age-blind, any position)
            $moveCount++
            $movedBytes += $entBytes[$k]
            $afterEst = $bytes - $movedBytes
            if ($afterEst -lt $lowMark) { break }
        }
        # (5) low-value pool exhausted and still over cap? distinguish value-floor vs keep-floor
        if ($afterEst -ge $cap) {
            $anyProt = $false
            for ($k = 0; $k -lt $floorStart; $k++) { if ($entProt[$k]) { $anyProt = $true; break } }
            if ($anyProt) { $warnValue = $true } else { $warnFloor = $true }
        }
    }

    # --- A6 gate the resolution wording on the strike count ---
    if ($warnValue) {
        $resolve = "WARN value-floor hit: over-cap but every drainable entry is value-protected/keep-floor - condense high-value BY HAND (do NOT age-archive)"
    } elseif ($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 lowest-value (position tiebreak) -> 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', $logCount, $cur, "~$afterEst", $resolve)
    # D3 value-gate: protected entries are EXCLUDED from the move-set (hard-skip), not merely flagged.
    $protCount = 0
    for ($k = 0; $k -lt $logCount; $k++) { if ($entProt[$k]) { $protCount++ } }
    if ($protCount -gt 0) {
        Write-Output ("    [D3 value-gate] $protCount value-protected logical entr(y/ies) HARD-SKIPPED from the drain set (kept regardless of position/age); move-set = lowest-value only. NOTE: 9-token grep = lower-bound; em-main must value-scan the WHOLE proposed set (paraphrase/spine leak remains).")
    }
}

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 EVERY index file: _INDEX.md + rotated _INDEX-<period>.md.
    # S167 split: a rotated index must NOT become a haystack - its own "substring:" lines would
    # self-resolve any needle and turn a lost archive record into a silent PASS)
    $contentFiles = Get-ChildItem -Path $archDir -Filter *.md | Where-Object { $_.Name -notlike '_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.
    #     S167 split: read EVERY _INDEX*.md (main + rotated) so pointers that moved to a
    #     rotated index file KEEP being verified - rotation must not silently shrink the net.
    $indexFiles = Get-ChildItem -Path $archDir -Filter '_INDEX*.md'
    $indexText = ($indexFiles | ForEach-Object { [System.IO.File]::ReadAllText($_.FullName, $utf8) }) -join "`n"
    $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:"<unique-string>"' 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)
            }
        }
    }
    # B2 @S152: 0 extracted pointers = NOT-MEASURED, never PASS (PASS-on-empty-denominator
    # = absence-looks-clean, the exact trap P2/S151-S4 codified; fd sat at "PASS 0/0" pre-A4).
    $verdict = if ($subPtrCount -eq 0) { "N/A " } elseif ($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.
# ==========================================================================
