Skip to content
Back to skills

Memory Hygiene Scan

ASecurity

Learning-sink entries — stale evidence, duplicates, contradictions, no provenance, stuck states, unreadable format.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythongobashreactgitapisecurity

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill memory-hygiene-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Memory Hygiene Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Memory Hygiene Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-memory-hygiene-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-memory-hygiene-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: memory-hygiene-scan
description: Learning-sink entries — stale evidence, duplicates, contradictions, no provenance, stuck states, unreadable format.
kind: skill
pack: learning
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: memory-hygiene-scan

## Premise

The learning loop writes observations into nine append-only sinks ([`learning-sink.md`](../../../../snippets/learning-sink.md)) and promotes the mature ones into the formal layer. Agents copy retrieved memories on similar tasks, so a wrong entry spreads (Xiong 2025); provenance-less memory can be planted (MINJA, preprint); current memory agents are weak at resolving conflicts (MemoryAgentBench). Entries rot by their **evidence**: the path an entry cites is deleted, the line it cites is past the end of the file, the rule it corrects was removed. And consumers break silently when a project reshapes a sink: on a real NestJS + React monorepo the drift log declares its own format (no `Status:` line; an entry is deleted on fix), so the curator's "entries with status `OPEN`" reads 0 of 76 entries and reports nothing to do.

`scripts/sink-audit.py` (this skill) parses each sink by the format it **declares** in its own `## Format per entry` block first (P-24), the template second, and owns: §1 stale evidence (KN-20), §2 duplicates (KN-21), §3 contradiction pairs for a judge (KN-22), §4 provenance (KN-23), §5 stuck lifecycle (KN-24), §6 n=1 universal lessons (KN-25, advisory), §7 checkable lessons with no enforcement (KN-26), §8 consumer contracts (KN-27), §9 the eval guard count (KN-36).

**Every finding cites the sink `file:line` of the entry + its heading + what failed (the citation and how it no longer resolves, the duplicate's site, the missing field, the state and its age, the consumer's regex and its match ratio) + the fix that keeps history (mark `RESOLVED → <sha>` or `SUPERSEDED` with a reason; never delete an entry).** "Memory clean" is never printed without the class-coverage block.

## The rule

An entry is a finding when all four hold:

1. **It is open.** Its status is not terminal (RESOLVED, REJECTED, SUPERSEDED, PROMOTED, DISCARDED, REVERSED, ARCHIVED, FIXED, CLOSED); a terminal entry citing a deleted path is history (§ 3).
2. **The sink's own contract applies.** The fields, states and heading shape come from the sink's declared format; an undated `### ` heading in a sink that declares dated entries is a section, not an entry, unless it carries a declared field.
3. **The evidence is checkable.** The entry cites a path, a line, a package or a rule. A memory with no code anchor (a decision, a preference) has provenance "user, <date>" and is never stale by code (§ 19).
4. **The fix keeps the record.** Re-verify and mark, merge two entries into one keeping both citations, promote or record why not, or hand a checkable lesson to enforcement. The curator invariant is "archive, don't delete".

## Boundaries

- **A citation in a non-sink knowledge file** → `knowledge-ref-scan` §1 (paths) and `knowledge-fact-scan` §6 (lines). This skill reads only the sinks.
- **A lesson about a convention** that should be enforced → align (AL-03 "declared, not enforced") when the align pack is installed; §7 here covers lessons, feedback and failures. Without align, convention lessons are listed here, tagged `convention (owner pack align not installed)`.
- **Eval quality** — toothless, stale or regressed cases → `/eval --coverage` grades; §9 here is a count.
- **A secret pasted into an entry** (KN-34) → security `secret-scan-method` when the security pack is installed; `context-load-scan` §5 hands it the file list.
- **The host memory directory** (`~/.claude/projects/<p>/memory/`) is outside the repo: read only with `--memory-dir=` (owner decision O-3). MEMORY.md's 200-line / 25 KB load limit is `context-load-scan` §1.

## Adapt to the codebase — the sink lanes

| Sink | Declared format read | Status vocabulary | Promotion threshold (learning-sink.md) | Consumer contracts checked (§8) |
|---|---|---|---|---|
| `ai/dynamic/learnings.md` | its `## Format per entry` fence | RAW · PROMOTE_CANDIDATE · PROMOTED · DISCARDED (as declared) | 3 sightings + ≥ 2 cited examples → `ai/conventions.md` | `/recall` dated headings |
| `ai/dynamic/learned-patterns.md` | same | WATCHING · READY · PROMOTED · REJECTED | READY (≥ 3 files, ≥ 2 weeks) → `ai/patterns/` | `session-start.sh` `^Status: READY` |
| `ai/dynamic/feedback-learned.md` | same | WATCHING · RULE_CANDIDATE · PROMOTED · REJECTED | `Repeated >= 2` → `.claude/rules/` | — |
| `ai/dynamic/decisions-pending.md` | same | WATCHING · VALIDATED · PROMOTED → ADR · REVERSED | VALIDATED → an ADR in the detected home | — |
| `ai/dynamic/drift-log.md` | same (projects often redefine it) | as declared; the template's OPEN/RESOLVED | mark RESOLVED | `session-start.sh` `^Severity: high`; curator duty 4 `status OPEN` |
| `ai/failures/_index.md` | same | as declared | stays (don't-retry catalog) | — |
| `ai/dynamic/vocabulary.md` | same | RAW · PROMOTE_CANDIDATE · PROMOTED · DISCARDED | 2 sightings + 1 citation → glossary | — |
| host memory dir (opt-in) | topic-file frontmatter | — | — | MEMORY.md index lines (P-18) |

The stuck-state threshold defaults to 14 days (the learned-patterns "≥ 2 weeks" rule); `--stuck-days=` changes it, and the coverage block prints the value used.

## Pre-flight

Run as **one** bash script from the repo root. Step **N** feeds detector **§N**. Every `rg` names its paths; any `rg` exit 2 is `blocked`.

```bash
[ -n "${BASH_VERSION:-}" ] || { echo "pre-flight: run it with bash (arrays and PIPESTATUS are bash-only)"; exit 2; }
KS="${KS:-.claude/skills}"        # installed engines; in the Refract repo itself: templates/packs/learning/skills
RUN="${RUN:-$(mktemp -d "${TMPDIR:-/tmp}/knowledge.XXXXXX")}"; mkdir -p "$RUN"; echo "RUN=$RUN"
export GIT_OPTIONAL_LOCKS=0 DO_NOT_TRACK=1 CI=true
SINK_PATHS=(); for p in ai/dynamic ai/failures/_index.md; do [ -e "$p" ] && SINK_PATHS+=("$p"); done
[ ${#SINK_PATHS[@]} -gt 0 ] || { echo "§1-§9 N/A: no learning sinks (ai/dynamic/, ai/failures/_index.md)"; exit 0; }

# 1-9. The engine: declared formats, entries, citations re-resolved, duplicates, polarity pairs, provenance,
#      lifecycle ages, enforcement lookup (lint configs, hooks, CI, tests), consumer contracts, eval count.
python3 -I "$KS/memory-hygiene-scan/scripts/sink-audit.py" . > "$RUN/sinks.tsv" 2> "$RUN/sinks.ledger"
echo "sink-audit exit $? (0 clean · 1 rows · 2 blocked)"; cat "$RUN/sinks.ledger"; cut -f1-7 "$RUN/sinks.tsv"
#    Opt-in, never by default (O-3): add --memory-dir="$HOME/.claude/projects/<encoded>/memory" to the line above.

# Cross-checks.
# 0. Entry headings per sink: the engine's `entries` must be explained against this (sections are not entries).
rg -c '^### ' -g '*.md' "${SINK_PATHS[@]}"; echo "rg 0 exit $?"
# 5. State lines as written (column 0): states the engine counts as stuck or non-standard.
rg -o --no-filename '^Status: [A-Z_]+' -g '*.md' "${SINK_PATHS[@]}" | sort | uniq -c; echo "rg 5 exit ${PIPESTATUS[0]}"
# 8. Consumer greps in installed hooks: every one the engine checks, and any it does not know yet.
[ -d .claude/hooks ] && { rg -n --pcre2 'grep[^\n]*"\^[A-Z][a-z]+:[^"]*"[^\n]*ai/(?:dynamic|failures)/' .claude/hooks; echo "rg 8 exit $?"; }
```

Read the ledger first: per sink, the entry count, whether the format is declared (and its fields) or the template is assumed, and citations ok / gone / past-EOF; per consumer contract, how many entries it matches. A sink the engine could not parse is `blocked`, never empty.

**Measured** (2026-10-19), read-only on a real NestJS + React monorepo (8 of 9 sinks present, every one declaring its own format; 0.9 s; 0 files written):
- §1: drift log 76 entries, citations 178 resolving / 6 gone / 33 past the end of the file → 29 stale entries; learnings 3 entries cite a deleted `tailwind.config.ts` or a moved wire file; 1 READY pattern cites a line past EOF. The first cut also read `this.tx.run` and `engine.io` as files: a citation now needs a known file extension.
- §5: both READY patterns stuck 79 days, both PROMOTE_CANDIDATE learnings stuck 43–44 days (the research's own count), and 1 of 3 patterns with no state.
- §7: 1 lesson with a checkable subject (`@utility`) and no lint, hook or test naming it — Unconfirmed, because it describes a Tailwind ordering rule, not a banned import.
- §8: the curator's `status OPEN` contract reads **0 of 76** drift entries: the sink declares a format with no Status field (P-24). The session hook's `^Severity: high` reads the 1 entry that carries the line.
- §9: 0 eval cases; 0 of 11 promoted project units guarded.
- §2, §3, §4: 0 rows after the fixes. A first cut paired 12 "contradictions" that were drift entries quoting both sides of a divergence and lines whose only shared subject was a path (`apps/web`); §3 now pairs lessons, patterns, feedback, pending decisions and failures against `CLAUDE.md` and rules, on code subjects, with imperative polarity only (§ 31). §4 first flagged 7 failure entries dated `2026-08` (month precision is a date) and 2 vocabulary section headings (not entries).
- On `benchmarks/fixtures/*` (24 trees): 0 rows beyond the §9 count line — no fixture carries learning sinks. The self-test seeds 12 rows (gone path, past EOF, n=1 universal lesson, a lesson contradicting `CLAUDE.md`, an unenforced import ban, an undated unsourced entry, a duplicate heading, a stuck PROMOTE_CANDIDATE and a stuck READY, the curator's population contract, a `Severity` written as a list item the hook cannot read, a `- **Status:** READY` entry stuck) and 8 decoys (a resolving entry, a RESOLVED entry citing a deleted path, an enforced ban, a safety lesson from one incident, a "user, <date>" provenance, a declared format without Status, dotted identifiers, `Severity: low` at column 0 under a `^Severity: high` hook).
- On `stale-knowledge` (the seeded fixture; 5 sinks, each declaring its format; `--today=2026-10-12`): 28 entries, citations 24 ok / 2 gone / 1 past EOF → 17 rows plus the §9 count, all on seed sites — **17 seeds printed**: three stale entries, two duplicates, two judge pairs, an undated and an unsourced entry, READY for 30 days and `Repeated: 3` for 41, two one-incident universal lessons (advisory), an unenforced import ban and an unchecked `lock_timeout` lesson, and the session hook's two template greps (0 of 5 and 0 of 2 entries read). `--eval-coverage`: 4 of 5 units guarded. The toothless case is `/eval`'s to grade. Decoys: 0 of 26. A subset contract counts an entry as unreadable only when its field is outside the consumer's shape (`Severity: low` at column 0 is read), and `- **Status:** READY` (colon inside the bold) reads as READY.

## The 3,000-hit path

A sink with hundreds of entries (a long-lived drift log) can return hundreds of rows.

1. **Group by cause.** Stale entries that cite files deleted in one commit are one re-verification; a contract that reads 0 of N is one row, not N.
2. **Close what moved, then what is old.** Entries whose evidence is gone first (they mislead now), then stuck states by age, then provenance.
3. **Judge pairs in batches.** §2 near-duplicates and §3 polarity pairs carry both quotes; one judge pass reads them all, and only pairs a second reader confirms become findings.
4. **Ledger the rest** as `Live, unreviewed (k entries)` with `sink-audit.py . --classes=<n>`.

## Scans for

| § | Class | Default |
|---|---|---|
| 1 | KN-20 stale entry: cited path gone, line past EOF | M (L in the failures catalog: a lesson can outlive its code) |
| 2 | KN-21 duplicate entries within or across sinks | L |
| 3 | KN-22 contradicting entries, or an entry vs `CLAUDE.md` / a rule | judge pair; H when the other side loads every session |
| 4 | KN-23 entry without date or source | M without a source, L without a date |
| 5 | KN-24 stuck lifecycle, stateless entries, undeclared states | L; M past 4× the threshold |
| 6 | KN-25 universal lesson from one case | L, advisory (Unconfirmed only) |
| 7 | KN-26 checkable lesson with no hook, lint rule or test | M |
| 8 | KN-27 consumer cannot read the sink's format | M |
| 9 | KN-36 knowledge with no guarding eval case | a count (`/eval --coverage` grades) |

### 1. Stale entry (KN-20)

```
BAD:   ### 2026-09-01 — api: orders helper moved      - Where: `src/legacy-orders.ts:10`   (deleted)
       ### 2026-09-02 — api: list past end            - Where: `src/orders.ts:205`         (file has 120 lines)
GOOD:  ### 2026-09-04 — RESOLVED api: old thing       (terminal: history) · a preference entry with no code anchor (P-19)
```
**Read the code:** Did the code move (repoint), was the drift fixed (mark `RESOLVED → <sha>`), or did the failure lesson outlive its code (mark `SUPERSEDED` with a reason, keep the lesson)? Does the line quote another codebase (P-8)? **Finding when** an open entry's citation no longer resolves or points past the end of the file. **Proof:** tier S + V499 — the citation and the `git log` that moved it; after the fix the entry re-resolves or carries RESOLVED/SUPERSEDED with a reason (`sink-audit.py . --only=<sink>:<line>` quiet).

### 2. Duplicate entries (KN-21)

```
BAD:   learnings.md:23 and :32 — "axios must not be imported" twice, two citations
GOOD:  MEMORY.md index line + its topic file (P-18) · two entries on one topic with different subjects
```
**Read the code:** Same correction, or two sightings that should increment one entry's count? **Finding when** two open entries have the same normalised heading (Candidate) or ≥ 0.8 token overlap (a judge pair). **Proof:** V500 — one entry remains with both citations and its sighting count updated.

### 3. Contradiction pair (KN-22)

```
BAD:   learnings.md:13 "Never wrap `transactions` around reads"  vs  CLAUDE.md:2 "Always use `transactions` around a money write"
GOOD:  a pair marked SUPERSEDED (P-14) · scopes that do not overlap (reads vs money writes is a judge question, not a regex one)
```
**Read the code:** Do both lines govern the same subject in the same scope? Is one newer and explicit (the newer user statement wins)? **Finding when** a judge reading both quotes agrees they cannot both hold; the engine only pairs them (`judge` rows). **Proof:** V500 — one side marked SUPERSEDED with a reason, or both rewritten with their scopes.

### 4. Provenance (KN-23)

```
BAD:   ### Undated thing with no source        (no date, no Source/Where, no citation)
GOOD:  "Source: user, 2026-08-05" (P-19) · a heading dated 2026-08 (month precision)
```
**Read the code:** Which fields does the sink declare? Is there evidence elsewhere in the entry (a PR, a commit, a URL)? **Finding when** an entry has no date, or no source and no citation. **Proof:** V501 — the entry carries a date and a source.

### 5. Stuck lifecycle (KN-24)

```
BAD:   learned-patterns.md:85  Status: READY for 79 days, never promoted
       learnings.md:80        Status: PROMOTE_CANDIDATE for 44 days
GOOD:  a state the sink declares that is not a promotion state · a sink that deletes entries on fix and declares no Status (P-24)
```
**Read the code:** Was it promoted under another name (an `ai/patterns/` file, a rule, an ADR)? Did the owner decide not to promote? **Finding when** an entry sits in a promotion state past the threshold, an entry has no state in a sink whose format declares one, or a state is not in the sink's declared vocabulary. **Proof:** V501 — promoted (`/promote-pattern`, `/promote-decision`) or a recorded reason.

### 6. Universal lesson from one case (KN-25)

```
BAD:   "never use transactions in read paths" — one citation
GOOD:  "never force-push to main" — one incident is enough for an irreversible action (P-20)
```
**Read the code:** Is the scope really universal? Is it a safety or irreversible action? Advisory only: it never becomes a Finding without a second case or a judge. **Proof:** V501 — the lesson scoped, or a second cited case.

### 7. Checkable lesson with no enforcement (KN-26)

```
BAD:   "Never import `axios` directly; use the http client" — no lint rule, hook, CI step or test names `axios`
GOOD:  "Never import `moment`" — `no-restricted-imports` names it in eslint.config.mjs
```
**Read the code:** Is the subject matchable (an import, an API call, a path)? Is it a convention (align's)? **Finding when** an open lesson bans or requires a matchable subject and no lint config, hook, CI workflow, Semgrep rule or test file names it. **Proof:** V502 — a real failing lint rule or test on a violating snippet, then green on the fixed one; the prose keeps one pointer line.

### 8. Consumer cannot read the format (KN-27)

```
BAD:   curator duty 4 reads drift entries "with status `OPEN`" — the sink declares no Status field: 0 of 76 match
       session-start greps `^Severity: high` — two entries write `- Severity: high` in a list
GOOD:  a sink that declares its own format and a consumer that reads that format (P-24)
```
**Read the code:** Which side moved — the sink's declared format or the consumer's template assumption? Is the consumer a filter (a subset by design: compare entries carrying the field in any shape with those it reads) or a population reader (ratio < 0.5)? **Finding when** a consumer's pattern matches under half of a sink's entries that it is meant to read, or misses entries that carry its field in another shape. **Proof:** V503 — after the format or consumer fix, the ratio is ≥ 0.5 (population) or every carrier matches (subset).

### 9. Eval guard count (KN-36)

A count only: `N case(s); k of m promoted unit(s) guarded`, where units are project patterns and project rules without a `pack:` frontmatter. `/eval --coverage` grades UNGUARDED, TOOTHLESS and STALE; `sink-audit.py . --eval-coverage` prints the JSON it reads.

## Proof

Oracles: [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Learning — V499 (entry re-resolves or carries a terminal state with a reason), V500 (duplicate merged / contradiction resolved, both quotes on record), V501 (provenance and lifecycle), V502 (lesson → a failing-first lint rule or test), V503 (consumer ratio). `sink-audit.py . --only=<sink>:<line>` fires before the edit and is quiet after it; `--entry=<sink>:<line>` is the reconcile-on-write check `/learn-from-task` runs before appending.

## Output

```
memory-hygiene-scan — 8 of 9 sinks · formats declared 8 · stuck threshold 14 d · memory dir NOT READ (O-3)

1. .claude/agents/knowledge-curator.md:115                                  [report-with-fix]   medium
   §8 KN-27 — "drift entries with status `OPEN`" reads 0 of 76 ai/dynamic/drift-log.md entries: the sink declares
   Where/Drift/Suggested fix and deletes an entry on fix (P-24). Fix: the curator reads the declared format.
2. ai/dynamic/drift-log.md:102 (+28 entries)                                [report-with-fix]   medium
   §1 KN-20 — `apps/api/src/bootstrap.ts:336` past EOF (224 lines). Fix: re-verify; RESOLVED → <sha> or re-point.
3. ai/dynamic/learned-patterns.md:85                                        [report-flagged]    medium
   §5 KN-24 — READY for 79 days, never promoted. Unproven: promoted under another name? Settling: rg the pattern name in ai/patterns/.

Verification: 41 candidates → 33 findings · 6 unconfirmed · 0 hardening · 0 pre-existing · 2 refuted

Class coverage:
  §1  KN-20 stale evidence     checked (263 citations → 33 entries)
  §2  KN-21 duplicates         checked (109 entries → 0)
  §3  KN-22 contradictions     checked (0 pairs)
  §4  KN-23 provenance         checked (0)
  §5  KN-24 lifecycle          checked (threshold 14 d → 5)
  §6  KN-25 n=1 universal      checked (advisory → 0)
  §7  KN-26 lesson → check     checked (1 unconfirmed)
  §8  KN-27 consumer contract  checked (3 contracts → 1)
  §9  KN-36 eval guards        0 cases; 0 of 11 units guarded (count only)
```

`[report-with-fix]`: the entry's evidence or contract is shown at both ends and the fix keeps history. `[report-flagged]`: one link is open (promoted under another name, a judge pair, a convention lesson). The coverage block lists **all nine** classes on every run: `checked`, `N/A (<reason>)` (no sinks, no consumer installed, no eval dir), or `blocked (<reason>)`.

## False positives / precedents

The verifier reads [`precedents/learning.md`](../../../../snippets/precedents/learning.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 10. The ones this skill meets most: § 3 history (terminal entries), § 8 another codebase, § 14 history-marked pairs, § 18 index plus topic, § 19 preference memories, § 20 safety lessons, § 24 declared formats, § 25 gitignored session files, § 31 drift entries are not rules.

## When to run

- On any diff that touches `ai/dynamic/**`, `ai/failures/_index.md`, `ai/evals/**`, a consumer of the sinks (`.claude/hooks/session-start.sh`, the curator, `/recall`), or deletes or moves files the sinks cite: the `/audit-knowledge` dispatch signal.
- Before `/learn-from-task` appends an entry: `--entry=<sink>:<line>` (duplicate, contradiction, provenance).
- On every whole-repo `/audit-knowledge`, reporting all nine classes.

## Halt conditions

- Do not report a row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) (§ 10, with [`precedents/learning.md`](../../../../snippets/precedents/learning.md)). Under `/audit-knowledge` that happens in its verification phase; run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- Never delete an entry to close a row: mark RESOLVED, SUPERSEDED or DISCARDED with a reason (the curator invariant).
- Never promote a judge pair (§2 near-duplicate, §3 contradiction) or an §6 advisory row to a Finding without both quotes and a second reader.
- Never read the host memory directory without `--memory-dir` (O-3), and never write into the scanned repository.
- Never print "memory clean" without the coverage block; a sink that did not parse is `blocked`.
- Do not re-derive a class another skill owns (§ Boundaries). Cross-link it.

## Related

- `scripts/sink-audit.py` (this skill): §1–§9, `--only`, `--entry`, `--memory-dir`, `--stuck-days`, `--today`, `--eval-coverage`, `--self-test`; imports `kn_common.py` from the knowledge-ref-scan skill (its scripts directory). Stdlib only, `python3 -I` safe; nothing is written under the scanned root.
- [`learning-sink.md`](../../../../snippets/learning-sink.md) — the nine sinks and their thresholds. `knowledge-curator` promotes; `/learn-from-task` writes; `/recall` reads; `/eval` grades. `/audit-knowledge` dispatches this skill; `/refresh-knowledge --from-audit` closes rows with the engine re-run as proof.
- align (when installed) owns convention lessons as AL-03; security (when installed) owns secrets pasted into entries.

Files in this skill

  • SKILL.md22.7 KB
  • scripts/sink-audit.py38.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.