#!/usr/bin/env bash
# scripts/memory-lint — advisory hygiene + reconsolidation-cadence check for the
# file-based memory (Claude Code's hand-edited `MEMORY.md` index + topic files).
#
# The harness owns this dir: it loads MEMORY.md every session, recalls topic
# files by relevance, and re-canonicalizes topic frontmatter on tool-writes. So
# MEMORY.md is HAND-EDITED (don't generate it), grouped into `## Threads` /
# `## Runbooks` / `## Gotchas` / `## Workflow` / `## Reference`. Policy: memory
# holds only live threads + durable knowledge; landed work is DELETED (the
# precis-mcp repo git log + ADRs are its record) — no ARCHIVE.md.
#
# Checks (all advisory — always exits 0; for `/whatneedsdoing`, not a gate):
#   1. broken index links + unindexed topic files.
#   2. landed-thread scan: a bullet under `## Threads` whose every cited commit
#      is in main + no open-work words → shipped; verify + delete (the auto
#      diagram-catch).
#   3. MEMORY.md over byte budget.
#   4. reconsolidation cadence (once/day).
#   5. (--currency only) per-claim currency ledger — treat each memory as a set
#      of falsifiable anchors and run the exact git+fs oracle for each, so the
#      once/day judgment pass gets a punch-list instead of re-reading every file.
#      Prior-art note (perplexity-research:164887, 2026-07-19): no widely-used
#      open-source Claude-Code memory tool verifies memories against repo ground
#      truth — this is the novel bit. Backlog: OPEN-ITEMS "Repo-dev" — 1-month
#      check-in on whether it earns its own pip.
#   6. sibling-repo path currency (always runs; internally weekly-gated, self-
#      stamping) — deliberately NOT part of check 5's in-repo path scan: that
#      scan explicitly skips external `~/work/<name>` paths as "legitimately
#      cross-repo", which is exactly the blind spot that let `~/work/cluster`
#      go stale for a month after its 2026-07-19 retirement (docs/runbooks/
#      memory-sibling-repos.md). Fully deterministic (`[[ -e ]]`), so unlike
#      token-review/db-thrash-review it needs no judgment pass — it runs
#      itself when due and appends its own log line.
set -euo pipefail
cd "$(dirname "$0")/.."

CURRENCY=0
for _a in "$@"; do [[ "$_a" == "--currency" ]] && CURRENCY=1; done

MAIN_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
MEMDIR="$HOME/.claude/projects/${MAIN_ROOT//\//-}/memory"
# Size is HYSTERETIC — don't fiddle at the margin. Let the index grow untouched
# up to the high-water mark; only then reconsolidate, and cut it back DOWN to
# ~low-water (not just under the mark) so it stays quiet for a long time after.
HIGH_WATER=20000
LOW_WATER=15000

if [[ ! -d "$MEMDIR" ]]; then
    echo "memory-lint: no memory dir at $MEMDIR (nothing to lint)"; exit 0
fi
INDEX="$MEMDIR/MEMORY.md"
problems=0

# 1a. broken links: [text](file.md) whose target is absent
while IFS= read -r f; do
    [[ -z "$f" || -f "$MEMDIR/$f" ]] || { echo "  broken link → $f"; problems=$((problems+1)); }
done < <(grep -hoE '\(([a-z0-9_]+)\.md\)' "$INDEX" 2>/dev/null | tr -d '()' | sort -u)

# 1b. unindexed topic files (referenced by neither a (file.md) link nor a [[slug]])
for tf in "$MEMDIR"/*.md; do
    b=$(basename "$tf"); [[ "$b" == MEMORY.md || "$b" == memory_consolidation_log.md ]] && continue
    slug="${b%.md}"
    grep -qE "\($b\)|\[\[$slug\]\]" "$INDEX" 2>/dev/null \
        || { echo "  unindexed → $b (index it, or if landed delete it — git log is the record)"; problems=$((problems+1)); }
done

# 2. landed-thread scan over the files listed under the `## Threads` section
open_re='unshipped|not deployed|NOT shipped|deferred|remain|residual|pending|blocked|dark|NEXT|TODO|WIP|design-only|proposal|roadmap|backlog'
threads=$(awk '/^## Threads/{f=1;next} /^## /{f=0} f' "$INDEX" 2>/dev/null \
    | grep -oE '\([a-z0-9_]+\.md\)' | tr -d '()' || true)
while IFS= read -r b; do
    [[ -z "$b" ]] && continue
    tf="$MEMDIR/$b"; [[ -f "$tf" ]] || continue
    if grep -qiE "$open_re" "$tf"; then continue; fi     # open-work words → keep
    shas=$(grep -oiE '\b[0-9a-f]{7,40}\b' "$tf" | grep -iE '[a-f]' | sort -u || true)  # need a letter
    [[ -z "$shas" ]] && continue
    landed=1
    while IFS= read -r s; do
        [[ -z "$s" ]] && continue
        git merge-base --is-ancestor "$s" main 2>/dev/null || { landed=0; break; }
    done <<< "$shas"
    if (( landed )); then
        echo "  landed thread → $b (all cited commits in main, no open-work words) — verify + delete"
        problems=$((problems+1))
    fi
done <<< "$threads"

# 3. index size — hysteresis: silent inside the band, flag only past high-water
bytes=$(wc -c < "$INDEX" | tr -d ' ')
if (( bytes > HIGH_WATER )); then
    echo "  MEMORY.md past high-water: $bytes > $HIGH_WATER B — reconsolidate DOWN to ~$LOW_WATER B: relocate detail into topic files + promote matured memories to permanent docs (make them permanent); delete only landed threads (git has them). NOT lossy word-trimming."
    problems=$((problems+1))
fi

# 3b. payload-smell — a topic file carrying fenced code blocks is holding a
# PAYLOAD (recipe body) where memory should hold a trigger + pointer
# (docs/runbooks/ owns recipe bodies — dev-context-diet). Advisory.
for tf in "$MEMDIR"/*.md; do
    b=$(basename "$tf"); [[ "$b" == MEMORY.md || "$b" == memory_consolidation_log.md ]] && continue
    fences=$(grep -c '^```' "$tf" 2>/dev/null || true); fences=${fences:-0}
    if (( fences >= 2 )); then
        echo "  payload-smell → $b ($((fences / 2)) code fence(s) — recipe body? move to docs/runbooks/, keep a symptom→pointer line)"
        problems=$((problems+1))
    fi
done

if (( problems == 0 )); then
    echo "memory-lint: ✓ clean ($bytes B, links resolve, no landed threads lingering)"
else
    echo "memory-lint: $problems hygiene issue(s) above"
fi

# 3c. dev-session preamble budget — CLAUDE.md + MEMORY.md load into every
# session; keep their combined weight visible (dev-context-diet). ~4 B/token.
claude_b=$(wc -c < CLAUDE.md 2>/dev/null | tr -d ' ' || echo 0)
pre_tok=$(( (bytes + claude_b) / 4 ))
PRE_BUDGET_TOK=6000
if (( pre_tok > PRE_BUDGET_TOK )); then
    echo "preamble OVER budget: CLAUDE.md+MEMORY.md ≈ ${pre_tok} tok > ${PRE_BUDGET_TOK} — diet per docs/backlog/dev-context-diet.md"
else
    echo "preamble: CLAUDE.md+MEMORY.md ≈ ${pre_tok} tok (budget ${PRE_BUDGET_TOK})"
fi

# 5. per-claim currency ledger (--currency) — the cheap, deterministic half of
#    the reconsolidation pass. git + filesystem oracles only (offline, exact);
#    gripe-status and deployed-sha oracles need the prod MCP → stay in the
#    judgment pass. Each finding is a SUSPECT for a human/Opus to resolve
#    (adjust the anchor / kill the memory / promote a decision to an ADR), never
#    an auto-delete: a gone branch may be shipped-and-pruned OR stranded work.
if (( CURRENCY )); then
    echo "— currency ledger (git+fs anchors; suspects only) —"
    # try every ref spelling a bare name could wear; 0 = exists, 1 = gone
    branch_gone() {
        local n="$1" ref
        for ref in "refs/heads/$n" "refs/heads/worktree-$n" \
                   "refs/remotes/origin/$n" "refs/remotes/origin/worktree-$n"; do
            git rev-parse --verify --quiet "$ref" >/dev/null 2>&1 && return 1
        done
        return 0
    }
    cur_suspect=0
    for tf in "$MEMDIR"/*.md; do
        b=$(basename "$tf")
        [[ "$b" == MEMORY.md || "$b" == memory_consolidation_log.md ]] && continue
        findings=()
        has_open=0; grep -qiE "$open_re" "$tf" && has_open=1

        # A memory RECORDS A LANDING when a positive "SHIPPED/MERGED … <sha>" line
        # cites a commit that is actually an ancestor of main. When it does, a gone
        # branch name elsewhere in that memory is just provenance (the landing is
        # already on record) — NOT a suspect. This is what kills the dominant
        # false-positive: historical branch names in shipped/deployed threads.
        # (Coarse by design: a memory that mixes a landed slice with a genuinely-
        # stranded branch is suppressed too — the judgment pass reads those threads
        # regardless; note "not shipped/unshipped/not deployed" lines are excluded so
        # an unshipped thread like open-namespace-teardown still surfaces.)
        has_landing=0
        while IFS= read -r _line; do
            printf '%s\n' "$_line" | grep -qiE 'shipped|merged' || continue
            printf '%s\n' "$_line" | grep -qiE 'not +shipped|unshipped|not +deployed|not +merged' && continue
            while IFS= read -r s; do
                [[ -z "$s" ]] && continue
                git merge-base --is-ancestor "$s" main 2>/dev/null && { has_landing=1; break; }
            done < <(printf '%s\n' "$_line" | grep -oiE '\b[0-9a-f]{7,40}\b' | grep -iE '[a-f]')
            (( has_landing )) && break
        done < "$tf"

        # (a) branch / worktree names — only when the memory claims LIVE unshipped
        #     work (has_open) AND records no real landing (! has_landing): a named
        #     branch now gone is shipped-and-pruned (prune the thread) OR stranded
        #     dark work (recover it) — either way, resolve.
        #     Require a hyphen: this repo's branches/worktrees are always kebab-case
        #     (worktrees are `adjective-verbing-noun`), so a hyphen cleanly rejects
        #     English words that follow "branch"/"worktree" in prose ("branch
        #     deleted", "worktree files"); bare real names (newenv, bettersearch)
        #     still surface via their hyphenated `worktree-<name>` spelling.
        if (( has_open )) && (( ! has_landing )); then
            while IFS= read -r n; do
                [[ -z "$n" ]] && continue
                n="${n%%[.,;:)\`]*}"                         # strip trailing punctuation
                [[ "$n" == *-* ]] || continue                # kebab-only → drops prose words
                branch_gone "$n" && findings+=("branch/worktree '$n' gone — shipped-and-pruned (prune) or stranded dark work (recover)?")
            done < <( { grep -oiE 'branch +`?[a-z0-9][a-z0-9._-]+' "$tf" | sed -E 's/^branch +`?//I';
                        grep -oiE 'worktree-? *`?[a-z0-9][a-z0-9-]+' "$tf" | sed -E 's/^worktree-? *`?//I'; } | sort -u )
        fi

        # NB: no commit-sha currency check. Cross-repo shas (memories legitimately
        # cite `~/work/cluster`) and squash-merge sha-rewrites (the original
        # worktree commit is gc'd once its work lands under a new squash sha) make
        # "sha unknown to repo" mostly false-positive. Check #2 already covers the
        # only reliable direction — all cited shas in main + no open-work → landed.

        # (b) repo paths the memory names that no longer exist on main. Anchor the
        #     match to a CLEAN boundary (start-of-line or a non-path char) so a repo
        #     path embedded inside a FOREIGN one doesn't false-positive as a missing
        #     precis-mcp path: /opt/shared/scripts/pg_backup.sh (a cluster host path)
        #     and infrastructure/…/scripts/regen-secrets-env.sh (the infra repo) both
        #     end in `scripts/…` but neither is ours — the `/` before `scripts` is a
        #     path char, so the boundary anchor rejects them.
        while IFS= read -r p; do
            [[ -z "$p" ]] && continue
            [[ -e "$MAIN_ROOT/$p" ]] || findings+=("path $p missing on main (renamed/deleted → fix or drop the ref)")
        done < <(grep -oE '(^|[^A-Za-z0-9_/.-])(src|docs|scripts|tests)/[A-Za-z0-9_./-]+\.[A-Za-z0-9]+' "$tf" \
                   | sed -E 's/^[^A-Za-z0-9]+//' | sort -u)

        if (( ${#findings[@]} )); then
            cur_suspect=$((cur_suspect+1))
            echo "  $b:"
            for f in "${findings[@]}"; do echo "    - $f"; done
        fi
    done
    if (( cur_suspect == 0 )); then
        echo "  ✓ all git+fs anchors verify"
    else
        echo "  currency: $cur_suspect file(s) with stale anchors — adjust / kill / promote-to-doc (judgment pass)"
    fi
fi

# 4. reconsolidation cadence (once/day)
# The log is appended oldest-first, so pick the chronologically LATEST pass
# date — ISO dates sort lexically, so `sort | tail -1` is the max regardless of
# file order (a bare `head -1` read the OLDEST entry and left the check
# permanently DUE after day one).
#
# Match the date that OPENS a log entry, whatever markdown dresses it in.
# **The prefix class is deliberately permissive, and that is the whole fix.**
# This clock has frozen three times, each time because the log's own style
# drifted past a prefix the regex enumerated: requiring `**bold**` froze it at
# 2026-08-13 for two weeks (seven passes unseen) when entries went plain and
# `- `-prefixed; allowing only `-` froze it at 2026-08-28 when entries became
# `## ` headings. Twice the recorded fix was to RE-STAMP one log line in the
# parseable form (see the 2026-08-07 and 2026-08-16 entries, which say so) —
# which fixes that run and leaves the trap armed. Any leading run of markdown
# punctuation now counts, so the next style drift costs nothing.
#
# A frozen clock is worse than no clock: it reports DUE forever, so the reader
# learns the line means nothing and stops acting on it. Anchoring at line-start
# (rather than accepting a date anywhere) is still what keeps a date quoted
# mid-prose from counting as a pass.
today=$(date -u +%Y-%m-%d)
last=$(grep -oE '^[-#*[:space:]]*[0-9]{4}-[0-9]{2}-[0-9]{2}' "$MEMDIR/memory_consolidation_log.md" 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | sort | tail -1 || true)
if [[ -z "$last" ]]; then
    echo "reconsolidation: DUE (no consolidation log yet)"
elif [[ "$last" == "$today" ]]; then
    echo "reconsolidation: done today ($last) — skip the heavy pass"
else
    echo "reconsolidation: DUE (last $last) — run 'scripts/memory-lint --currency' for the per-claim ledger, then judgment-resolve each suspect (adjust / kill / promote-to-doc) + delete any landed thread flagged above. Compact for SIZE only if past the high-water mark. Append a dated line to memory_consolidation_log.md"
fi

# 6. sibling-repo path currency — always evaluated, internally weekly-gated,
#    self-stamping (fully mechanical, no judgment pass needed). See
#    docs/runbooks/memory-sibling-repos.md.
SIB_LOG="docs/runbooks/memory-sibling-repos.md"
SIB_WINDOW_DAYS=7
epoch_of() { date -u -j -f "%Y-%m-%d" "$1" +%s 2>/dev/null || date -u -d "$1" +%s 2>/dev/null; }
# Log is appended oldest-first, so pick the chronologically LATEST stamp
# (`sort | tail -1`, ISO dates sort lexically). A bare `head -1` read the OLDEST
# entry, so the weekly gate stayed permanently DUE and re-stamped on every run
# — the same trap fixed for the reconsolidation clock above.
#
# Same permissive prefix as that clock, for the same reason: this log's entries
# are `**bold**` TODAY, and requiring bold is precisely what froze the
# reconsolidation clock twice. The `awk` already scopes the search to after the
# `## Log` heading and the `^` anchor keeps a mid-prose date out, so accepting
# any leading markdown punctuation costs nothing and disarms the drift.
sib_last=$(awk '/^## Log/{f=1;next} f' "$SIB_LOG" 2>/dev/null \
    | grep -oE '^[-#*[:space:]]*[0-9]{4}-[0-9]{2}-[0-9]{2}' \
    | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | sort | tail -1 || true)
sib_last_e=$( [[ -n "$sib_last" ]] && epoch_of "$sib_last" || true )
sib_days=999999
[[ -n "${sib_last_e:-}" ]] && sib_days=$(( ($(date +%s) - sib_last_e) / 86400 ))

if (( sib_days <= SIB_WINDOW_DAYS )); then
    echo "sibling-repo check: ok (last $sib_last, ${sib_days}d ago; due in $(( SIB_WINDOW_DAYS - sib_days ))d)"
else
    echo "sibling-repo check: DUE (last ${sib_last:-never}) — running now"
    sib_findings=()
    for tf in "$MEMDIR"/*.md; do
        b=$(basename "$tf")
        [[ "$b" == MEMORY.md || "$b" == memory_consolidation_log.md ]] && continue
        while IFS= read -r root; do
            [[ -z "$root" ]] && continue
            name="${root##*/}"
            [[ "$name" == "precis-mcp" ]] && continue   # this repo — no signal
            expanded="${root/#\~/$HOME}"
            [[ -e "$expanded" ]] || sib_findings+=("$b: $root — no longer on disk (repo retired/renamed? fix or drop the ref)")
        done < <(grep -ohE '~/work/projects/code/[A-Za-z0-9_-]+|~/work/[A-Za-z0-9_-]+|/Users/[A-Za-z0-9_-]+/work/projects/code/[A-Za-z0-9_-]+|/Users/[A-Za-z0-9_-]+/work/[A-Za-z0-9_-]+' "$tf" 2>/dev/null \
                       | sed -E 's#^/Users/[A-Za-z0-9_-]+/work/#~/work/#' | sort -u)
    done
    if (( ${#sib_findings[@]} == 0 )); then
        echo "  ✓ all sibling-repo paths verify"
        sib_note="✓ clean"
    else
        for f in "${sib_findings[@]}"; do echo "  - $f"; done
        sib_note="${#sib_findings[@]} stale path(s): $(printf '%s; ' "${sib_findings[@]}")"
    fi
    printf '\n**%s** — %s\n' "$today" "$sib_note" >> "$SIB_LOG"
fi

exit 0
