From a4918678f2a77b0e468e8dcd9eb19f5aefaef3c3 Mon Sep 17 00:00:00 2001 From: bowlerjim Date: Sun, 20 Sep 2026 23:34:56 -0400 Subject: [PATCH] fix(ProposalGC): ABSORBED requires the whole entry in the body, not a 60-char prefix (#2185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header states the invariant: "only three removal classes, each PROVABLE ... it cannot drop a live directive", with ABSORBED defined as "the entry's substantive text already appears verbatim in the file's canonical body". The code tested `normalize(entry).slice(0, 60)` — any proposal whose first 60 characters appear in the body was deleted whole. The colliding case is the ordinary one: the reviewer restates an existing rule in order to extend it. The restatement matches the body, and the extension is destroyed with it. This is not human-gated: `--auto`, the documented scheduled entry point, implies `--apply`. Require the entire normalized entry, keeping the 30-character floor. Verified on a fixture whose body holds a backup rule and whose proposals section holds (a) that rule restated with an added offsite-copy clause and (b) an exact restatement. Before: both classified `absorbed`, and `--apply` emptied the section. After: only the exact restatement is removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PuS77R8a6Lhp7jK6dnwdTh --- LifeOS/install/LIFEOS/TOOLS/ProposalGC.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/LifeOS/install/LIFEOS/TOOLS/ProposalGC.ts b/LifeOS/install/LIFEOS/TOOLS/ProposalGC.ts index 8dbc6c48d0..628dfdfe93 100755 --- a/LifeOS/install/LIFEOS/TOOLS/ProposalGC.ts +++ b/LifeOS/install/LIFEOS/TOOLS/ProposalGC.ts @@ -85,9 +85,16 @@ function appliedTs(entry: string): string { return m ? m[1].trim() : ""; } -/** Substantive core of an entry (first ~60 normalized chars) for absorption test. */ -function core(entry: string): string { - return normalize(entry).slice(0, 60); +/** + * Absorption test: the ENTIRE normalized entry must already appear in the + * canonical body. A prefix match is not proof of absorption — the reviewer + * routinely restates an existing rule in order to extend it, and a prefix + * test reads that restatement as redundancy and deletes the extension with + * it. The 30-char floor keeps a one-word fragment from matching anything. + */ +function absorbed(entry: string, bodyNorm: string): boolean { + const norm = normalize(entry); + return norm.length >= 30 && bodyNorm.includes(norm); } function gcFile(relPath: string): { removals: Removal[]; nextContent: string | null } { @@ -115,7 +122,7 @@ function gcFile(relPath: string): { removals: Removal[]; nextContent: string | n if (/\[SUPERSEDED/i.test(e.text)) { removals.push({ file: relPath, reason: "superseded", text: e.text.trim() }); removeIdx.add(e.i); - } else if (core(e.text).length >= 30 && bodyNorm.includes(core(e.text))) { + } else if (absorbed(e.text, bodyNorm)) { removals.push({ file: relPath, reason: "absorbed", text: e.text.trim() }); removeIdx.add(e.i); }