All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m23s
Adopt AI_INFRA 2026-07-13 broadcast ab6c387e (presence-not-age selector, type=update) + directed 6c32df89 rec-3, via /fable-clone 5-lane reviewer ensemble (wf_b621aac4-f0b) -> spec -> /fable-real deep-pass (PASS-WITH-FIXES, M1+M2 applied) -> HMW execute (em-main solo; governance single-writer D9). D2 (hmw.js): unknown-role fail-soft-WARN -> up-front STOP-HARD throw (before parallel; preserves null/'' role-less inherit-lead path). node --check + stub 7/7. Doc-sync 5 live lines (ultra-on/README/harness-11-engine/runbook). D3 (memory-archive-gate.ps1): value_protect advisory-flag -> pre-selection HARD-SKIP (value-primary; heading-only spans; non-contiguous byte accum) + value-floor WARN. Fault-inject ALL PASS + real regression A7 242/242. DRY-RUN. D1 (reinject-ledger.md): presence re-verify stamp + 3 honest-notes + BUILD-GAP. Re-verify: reinject (i) + MFE age-band (iii) already COMPLIANT; only the archive-gate age-trace needed hardening. D3 = defense-in-depth codify, NOT leak-closure (9-token grep unchanged; em-main value-scan stays the guarantee). adap-report + email AI_INFRA (8b9dc5165d5a); inbox STAGE-2 processed. No new User-Mark (codify-only). #53 garble x1 (lane-4) recovered from disk, 0 loss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
337 lines
16 KiB
PowerShell
337 lines
16 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)
|
|
}
|
|
}
|
|
}
|
|
$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.
|
|
# ==========================================================================
|