Files
solution-erp/scripts/memory-archive-gate.ps1
pqhuy1987 c2d01f0dce
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m30s
[CLAUDE] Docs: S152 closeout — bookend hình B trọn 2 đầu + owner (30)(31)(32) + queue memory-ops TRỌN (squash 20 wal:)
- Owner 3-quyết: (30) JUMP để-nguyên-theo-nhịp · (31) 4 persona +NEVER-block (restart ĐÃ THOẢ) · (32) vòng 1→5 AUTO — lô consent gỡ 9 site (5 H1 + ring1-tự-bắt + 3 trio; E-013 EXT)
- Memory-ops TRỌN: A1-A4/D1/D2/R1/B-series — A7 392/392 · mfe 20/20-100% lần đầu · lead-gap drain 24.728→16.485B · strike-ledger + WARN-latch (-Ack = owner-action)
- V4 sleep AUTO đầu: 1 shard thật/16 KÊU · ring4 đầu-đời TRƯỢT-4/5 → 4 fix gist (scribe sub-ring4-close vì return-only)
- Thư model re-stamp: 59ce1d0f → 9c909007 published (G-024a) — adap-report Đính-chính @S152 + sweep 13 bề-mặt LIVE
- Bookend @close: stale 6 FLAG + gap 2 FLAG → vá 8/8; ring2 8/8 ĐẠT (4-A pin-bản-cắt → slot 33) · ring1 48Đ/5T/59-claim
- M9 13 đơn-vị diary (3 S151-deep hồi-tố) · #53 ×10-chắc → tally 60-cận-dưới + errata subject-1b85713
- Session-log 2 run= + completeness-gate 5-vòng ĐẠT · HANDOFF segment S152 (carry 3-đóng/3-mới/23-giữ; slot 33/34)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:58:30 +07:00

339 lines
17 KiB
PowerShell

# 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 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:"<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.
# ==========================================================================