#!/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.
#
# GRAPH MODE (MEMORY.md carries the graph marker; cutover 2026-10-03, see
# docs/backlog/memory-native-authoring.md): the index is rendered from
# SPACE:repo-dev memory nodes and the memory dir's topic files are a frozen
# snapshot, so checks 2, 3b, 5 and 6 read the NODE bodies instead — the
# `precis memory index --export-dir` cache that scripts/hooks/session-start-
# memory.sh refreshes each session (one `<handle>.md` per node; default
# ~/.cache/precis/memory-nodes, PRECIS_MEMORY_NODES overrides). Findings name
# the handle and the graph fix (delete / edit(kind='memory')). Checks 1a/1b
# have nothing to read and stay skipped; with no node cache yet, 2/3b/5/6 are
# skipped with one line. Graph mode adds a stray-write check: a memory-dir file
# edited after MEMORY.md.pre-cutover was written to a file the graph never
# reads (a session that started before the cutover) and needs porting by hand.
#
# The export's manifest (_sections.tsv: handle, section, updated, body chars,
# chars the fisheye eye shows, live ring links, links hidden by the eye's
# per-group cap, dead-end links, part-of hub handle, live qualified-by count,
# last access date (viewed/recalled) —
# src/precis/cli/memory.py::SECTIONS_MANIFEST) carries what the bodies cannot: check 2 scopes by section, check 7 ages by
# the node's real updated_at (the export rewrites every file, so mtimes are
# all "today"), and check 8 lints the graph as recall walks it.
#
# Retire candidates (check 7, both modes): lists — never retires — notes the
# memory should drop: a thread-type note whose every resolvable cited sha is in
# main and whose NEXT line is empty/done, and any note untouched >30d. Cited
# shas are resolved ONCE (one `git cat-file --batch-check`, one `git rev-list
# main`) into $SHAMAP, which check 2 reads too.
#
# Checks (all advisory — always exits 0; for `/whatneedsdoing`, not a gate):
#   1. broken index links + unindexed topic files (file mode only).
#   2. landed-thread scan: a bullet whose every cited commit is in main + no
#      open-work words → shipped; verify + delete (the auto diagram-catch).
#      Runs over every linked topic file, not just those grouped under
#      `## Threads` — the live index is a flat list, and a missing/empty
#      `## Threads` section is itself flagged rather than silently no-op'd
#      (file mode). Graph mode scans the manifest's `threads` nodes; a manifest
#      with no sections at all is flagged and every node is scanned.
#   3. MEMORY.md (graph mode: the rendered index) over byte budget; 3b: payload
#      smell (fenced code blocks in a topic file / node body).
#   4. reconsolidation cadence (once/day; same clock and log in both modes).
#   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.
#   8. (graph mode) fisheye shape, per node, from the manifest: a body longer
#      than the eye shows (recall reads `fisheye+1hop`, which cuts the body),
#      an orphan (no live links: search is its only way in), neighbours the
#      eye hides past its per-group cap (a flat hub — route through a hub; a
#      section:index hub lists its members uncapped, so it is never flat),
#      dead-end links (to retired/missing refs); with the hub columns: a node
#      not part-of any section:index hub, a hub with >40 members (split into
#      sub-hubs), a threads node whose hub has gotchas but no qualified-by
#      edge on the thread, a non-hub node neither accessed nor updated in 60 days
#      (cold — retire or fold). A hub column names the nearest section:index
#      ancestor along part-of, so detail nodes under a summary node are
#      hubbed; the member count is direct (parent column) members only. A 2-/8-column manifest skips the hub checks,
#      a manifest without the access column skips cold; a node with an open
#      review todo (tag memory-review, linked todo -> node; the manifest's
#      review column) is listed as "under review", and as a problem once the
#      todo is open > 7 days; a manifest without that column skips it.
#   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"
# Graph-mode node bodies, exported by scripts/hooks/session-start-memory.sh.
NODEDIR="${PRECIS_MEMORY_NODES:-${XDG_CACHE_HOME:-$HOME/.cache}/precis/memory-nodes}"

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

# Graph mode (docs/backlog/memory-native-authoring.md, cutover 2026-10-03).
# MEMORY.md is then a pointer carrying GRAPH_MARKER, and the index is rendered
# from SPACE:repo-dev memory nodes by scripts/hooks/session-start-memory.sh,
# which saves its last good render to the same cache path computed here. The
# topic files are a frozen snapshot of the import, so every check that reads
# them would lint the snapshot, not the memory. The
# session-start render is only a small live-thread list, so there are no size
# or preamble checks; checks 2, 3b, 5 and 6 run over the exported node bodies
# (NODEDIR); 1a/1b have nothing to read and are skipped.
GRAPH_MARKER='<!-- memory-index: graph -->'
GRAPH=0
if grep -qF "$GRAPH_MARKER" "$INDEX" 2>/dev/null; then
    GRAPH=1
    INDEX="${PRECIS_MEMORY_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/precis/memory-index.md}"
    if [[ -s "$INDEX" ]]; then
        rendered=$(date -u -r "$INDEX" +%Y-%m-%dT%H:%MZ)
        echo "memory-lint: graph mode — index from the session-start render cached $rendered; checks 1a/1b skipped (the topic files are the cutover snapshot)"
    else
        echo "memory-lint: graph mode — no cached render yet (scripts/hooks/session-start-memory.sh writes it); size checks skipped"
        INDEX=/dev/null
    fi
fi

# SCAN_DIR: where checks 2, 3b, 5 and 6 read topic bodies — the memory dir in
# file mode, the node cache in graph mode (empty = those checks are skipped).
# ident/label_of name a file in findings: the file name in file mode, the
# handle (+ title) in graph mode.
SCAN_DIR="$MEMDIR"
if (( GRAPH )); then
    SCAN_DIR=""
    if compgen -G "$NODEDIR/*.md" >/dev/null; then
        SCAN_DIR="$NODEDIR"
        echo "memory-lint: graph mode — node bodies from $NODEDIR (exported $(date -u -r "$NODEDIR" +%Y-%m-%dT%H:%MZ)); checks 2, 3b, 5, 6 run over them"
    else
        echo "memory-lint: graph mode — no node cache yet — scripts/hooks/session-start-memory.sh writes it"
    fi
fi
is_topic() { [[ "$1" != MEMORY.md && "$1" != memory_consolidation_log.md ]]; }
ident() { if (( GRAPH )); then printf '%s' "${1%.md}"; else printf '%s' "$1"; fi; }
label_of() {   # label_of <basename> <path>
    if (( GRAPH )) && [[ ! -f "$2" ]]; then
        printf '%s (section node)' "${1%.md}"   # manifest-only: no body file
    elif (( GRAPH )); then
        printf '%s (%s)' "${1%.md}" "$(head -n 1 "$2" 2>/dev/null | sed -E 's/^# +//' | cut -c1-60)"
    else
        printf '%s' "$1"
    fi
}

# Graph mode only: a file edited in the memory dir after the cutover is a stray
# write — the graph never reads it. Reference point: MEMORY.md.pre-cutover (the
# index as it stood at the cutover); absent = nothing to compare against.
# Once ported, `touch -r` back to the reference clears the finding.
if (( GRAPH )) && [[ -f "$MEMDIR/MEMORY.md.pre-cutover" ]]; then
    for sf in "$MEMDIR"/*.md; do
        [[ -f "$sf" ]] || continue
        b=$(basename "$sf"); is_topic "$b" || continue
        if [[ "$sf" -nt "$MEMDIR/MEMORY.md.pre-cutover" ]]; then
            echo "  stray write → $b (edited after the cutover; the graph never reads it — port the body into its node by handle, then clear this with: touch -r \"$MEMDIR/MEMORY.md.pre-cutover\" \"$sf\")"
            problems=$((problems+1))
        fi
    done
fi

if (( ! GRAPH )); then

# 1a. broken links: [text](file.md) whose target is absent. The char class
# allows a hyphen — every real topic file is kebab-case
# (design-workbench-state.md), and without it this never matched a real
# filename at all (gr450546, same defect as check 2's link extraction below).
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
fi  # ! GRAPH (checks 1a, 1b)

# Batched sha oracle. One git process resolves every distinct cited sha and one
# lists main, instead of one `merge-base --is-ancestor` per sha (18 s on a
# ~150-note memory). SHAMAP lines: "<lowercase sha>\t<1 in main | 0 not in main |
# x unresolvable>".
epoch_of() { date -u -j -f "%Y-%m-%d" "$1" +%s 2>/dev/null || date -u -d "$1" +%s 2>/dev/null; }
SHAMAP=$(mktemp "${TMPDIR:-/tmp}/memory-lint-shas.XXXXXX")
trap 'rm -f "$SHAMAP" "$SHAMAP.main" "$SHAMAP.out"' EXIT
if [[ -n "$SCAN_DIR" ]]; then
    topic_files=()
    for _f in "$SCAN_DIR"/*.md; do
        [[ -f "$_f" ]] && is_topic "$(basename "$_f")" && topic_files+=("$_f")
    done
    if (( ${#topic_files[@]} )); then
        grep -ohiE '\b[0-9a-f]{7,40}\b' "${topic_files[@]}" 2>/dev/null | grep -iE '[a-f]' \
            | tr 'A-F' 'a-f' | sort -u > "$SHAMAP.out" || true
        if [[ -s "$SHAMAP.out" ]]; then
            git rev-list main > "$SHAMAP.main" 2>/dev/null || : > "$SHAMAP.main"
            sed 's/$/^{commit}/' "$SHAMAP.out" | git cat-file --batch-check 2>/dev/null \
                | paste -d' ' "$SHAMAP.out" - \
                | awk -v mainf="$SHAMAP.main" 'BEGIN { while ((getline l < mainf) > 0) m[l] = 1 }
                    { if ($3 == "commit") print $1 "\t" (($2 in m) ? 1 : 0); else print $1 "\tx" }' > "$SHAMAP" || true
        fi
    fi
fi
# sha_state <sha> → 1 (in main) | 0 (not in main) | x (unresolvable/unknown)
sha_state() {
    local r
    r=$(awk -F'\t' -v s="$(printf '%s' "$1" | tr 'A-F' 'a-f')" '$1 == s { print $2; exit }' "$SHAMAP")
    printf '%s' "${r:-x}"
}

# 2. landed-thread scan. The `## Threads` section is where the oracle below
# is meant to look, but the live index is a flat bullet list (no `##`
# headings) — a silently-empty slice used to make this whole check a no-op
# (memory-lint-threads-scan-dead.md). Two fixes: (a) an empty/missing
# `## Threads` slice is now its own FINDING, not a clean pass; (b) the scan
# itself runs over every linked topic file in the index regardless of
# section — headings, where they exist, only affect reading order, so the
# landed-or-not oracle applies uniformly whether or not the file is grouped.
# The link-extraction pattern allows a hyphen in the filename class: every
# real topic file is kebab-case (design-workbench-state.md), so without it
# the "widened" scan still matched nothing on the live index — same failure
# mode, one level down.
open_re='unshipped|not deployed|NOT shipped|deferred|remain|residual|pending|blocked|dark|NEXT|TODO|WIP|design-only|proposal|roadmap|backlog'
# Graph mode has no index links to follow. The export's _sections.tsv
# (handle<TAB>section) scopes the scan to `threads` nodes: runbooks and
# gotchas routinely cite the sha of a landed fix with no open-work words, and
# they are durable knowledge, not landed threads (first graph run, 2026-10-03:
# 11 of 11 flags were runbooks/gotchas). No manifest → every node is scanned.
# A manifest in which no node has a section is itself a finding (the 2026-10-09
# state: all 153 nodes unsectioned, so a threads-only scan covered nothing and
# reported clean); the scan then falls back to every node.
threads=""
MANIFEST=""
[[ -n "$SCAN_DIR" && -f "$SCAN_DIR/_sections.tsv" ]] && MANIFEST="$SCAN_DIR/_sections.tsv"
if (( GRAPH )); then
    if [[ -n "$MANIFEST" ]] && awk -F'\t' '$2 != "" && $2 != "index" { found = 1; exit } END { exit !found }' "$MANIFEST"; then
        threads=$(awk -F'\t' '$2 == "threads" { print $1 ".md" }' "$MANIFEST")
    elif [[ -n "$SCAN_DIR" ]]; then
        if [[ -n "$MANIFEST" ]]; then
            echo "  no memory node carries a section:<slug> tag — the index renders one flat Unfiled list and the landed-thread scan falls back to every node (runbooks/gotchas citing a landed fix may false-positive): tag each node into a section (tag(kind='memory', id=…, add=['section:threads']))"
            problems=$((problems+1))
        fi
        threads=$(cd "$SCAN_DIR" && ls -- *.md 2>/dev/null || true)
    fi
else
    threads_slice=$(awk '/^## Threads/{f=1;next} /^## /{f=0} f' "$INDEX" 2>/dev/null || true)
    # (not "${threads_slice//[[:space:]]/}": bash 3.2 is quadratic on that — 17 s here)
    if ! printf '%s' "$threads_slice" | grep -q '[^[:space:]]'; then
        echo "  MEMORY.md has no \`## Threads\` heading — landed-thread scan is disabled"
        problems=$((problems+1))
    fi
    threads=$(grep -oE '\([a-z0-9_-]+\.md\)' "$INDEX" 2>/dev/null | tr -d '()' | sort -u || true)
fi
while IFS= read -r b; do
    [[ -z "$b" ]] && continue
    tf="$SCAN_DIR/$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
        [[ "$(sha_state "$s")" == 1 ]] || { landed=0; break; }
    done <<< "$shas"
    if (( landed )); then
        if (( GRAPH )); then
            echo "  landed thread → $(label_of "$b" "$tf") — verify, then delete(kind='memory', id='${b%.md}')"
        else
            echo "  landed thread → $b (all cited commits in main, no open-work words) — verify + delete"
        fi
        problems=$((problems+1))
    fi
done <<< "$threads"

if [[ -n "$SCAN_DIR" ]]; then
# 3b. payload-smell — a topic file (graph mode: node body) 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 "$SCAN_DIR"/*.md; do
    b=$(basename "$tf"); is_topic "$b" || continue
    fences=$(grep -c '^```' "$tf" 2>/dev/null || true); fences=${fences:-0}
    if (( fences >= 2 )); then
        if (( GRAPH )); then
            echo "  payload-smell → $(label_of "$b" "$tf") ($((fences / 2)) code fence(s) — recipe body? move to docs/runbooks/, keep a symptom→pointer body: edit(kind='memory', id='${b%.md}'))"
        else
            echo "  payload-smell → $b ($((fences / 2)) code fence(s) — recipe body? move to docs/runbooks/, keep a symptom→pointer line)"
        fi
        problems=$((problems+1))
    fi
done
fi  # SCAN_DIR (check 3b)

# 8. fisheye shape (graph mode, manifest with the shape columns). Recall is
# search → get(view='fisheye+1hop+recall'), so a node is only as good as what
# that read shows: the eye cuts the body to a fixed cap, lists at most a fixed
# number of neighbours per relation, and hides dead ends. The caps live in
# src/precis/utils/eye_render.py; the export applies them, this only compares.
if (( GRAPH )) && [[ -n "$MANIFEST" ]]; then
    while IFS=$'\t' read -r h sec upd chars eye live hidden dead hubh qual hubgot members acc review gnone parsec; do
        [[ -n "$dead" ]] || continue          # pre-shape manifest: nothing to check
        tf="$SCAN_DIR/$h.md"
        if (( chars > eye )); then
            echo "  eye-truncated → $(label_of "$h.md" "$tf"): fisheye shows $eye of $chars chars — split it (a summary node + part-of detail nodes, one per thread/pass; keep the resume state first): edit(kind='memory', id='$h')"
            problems=$((problems+1))
        fi
        if (( live == 0 )); then
            echo "  orphan → $(label_of "$h.md" "$tf"): no live links, reachable by search only — link it to its hub and neighbours: link(kind='memory', id='$h', target='memory:<numeric id, no me prefix>')"
            problems=$((problems+1))
        fi
        if (( hidden > 0 )) && [[ "$sec" != index ]]; then   # hubs list members uncapped
            echo "  flat hub → $(label_of "$h.md" "$tf"): fisheye+1hop hides $hidden neighbour(s) past its per-relation cap — prune weak links or route them through a hub node"
            problems=$((problems+1))
        fi
        if (( dead > 0 )); then
            echo "  dead links → $(label_of "$h.md" "$tf"): $dead link(s) to retired/missing refs — repoint or unlink: get(kind='memory', id='$h', view='links')"
            problems=$((problems+1))
        fi
        if [[ -n "${review:-}" && "$review" != "-" ]]; then
            # open review todos (td<id>:<created>,…): informational while young,
            # a hygiene problem once open > 7 days
            IFS=, read -ra revs <<< "$review"
            for rv in "${revs[@]}"; do
                rid="${rv%%:*}"; rday="${rv#*:}"
                rday_e=$(epoch_of "$rday" || true)
                if [[ -n "$rday_e" ]] && (( ($(date +%s) - rday_e) / 86400 > 7 )); then
                    echo "  stalled review → $(label_of "$h.md" "$tf"): $rid open since $rday — settle it or escalate (waiting-for:reto): get(kind='todo', id='$rid')"
                    problems=$((problems+1))
                else
                    echo "  under review → $(label_of "$h.md" "$tf"): $rid (opened $rday)"
                fi
            done
        fi
        [[ "$hubh" != "?" ]] || continue      # pre-hub manifest: no hub checks
        if [[ "$sec" == index ]]; then
            if (( members > 40 )); then
                echo "  big hub → $(label_of "$h.md" "$tf"): $members members — split into sub-hubs"
                problems=$((problems+1))
            fi
            continue
        fi
        if [[ "$acc" != "?" ]]; then      # cold: no access and no update in 60 days
            last="$upd"; [[ "$acc" != "-" && "$acc" > "$last" ]] && last="$acc"
            last_e=$(epoch_of "$last" || true)
            if [[ -n "$last_e" ]] && (( ($(date +%s) - last_e) / 86400 > 60 )); then
                echo "  cold → $(label_of "$h.md" "$tf"): not recalled or updated since $last — retire or fold into a neighbour"
                problems=$((problems+1))
            fi
        fi
        if [[ "$hubh" == "-" ]]; then
            echo "  no hub → $(label_of "$h.md" "$tf"): not part-of any section:index hub — link it: link(kind='memory', id='$h', target='memory:<hub numeric id, e.g. 477655>', rel='part-of')"
            problems=$((problems+1))
        elif [[ "$sec" == threads ]] && [[ "$qual" != "?" ]] && (( qual == 0 && hubgot >= 1 )) \
                && [[ "$gnone" != 1 && "$parsec" != threads ]]; then
            echo "  unqualified thread → $(label_of "$h.md" "$tf"): hub $hubh has gotchas, none linked — link relevant ones with rel='qualifies', or tag gotchas:none if reviewed and none applies"
            problems=$((problems+1))
        fi
    # (tab is IFS whitespace, so `read` would merge an empty column: awk fills
    # section/hub with "-", marks absent hub/qualified columns "?", and adds
    # per-hub counts — gotchas (cols 2+9) and members — as trailing columns)
    done < <(awk -F'\t' -v OFS='\t' '
        FNR == NR {
            if (NF >= 9 && $9 != "" && $9 != "-") {
                # direct members of a hub: the part-of parent column when present
                direct = (NF >= 12) ? $12 : $9
                if (direct != "") members[direct]++
                if ($2 == "gotchas") gotchas[$9]++
            }
            if ($2 != "") secof[$1] = $2
            next
        }
        NF < 8 { if ($2 == "") $2 = "-"; print; next }
        {
            sec = ($2 == "") ? "-" : $2
            hub = (NF < 9) ? "?" : (($9 == "") ? "-" : $9)
            qual = (NF < 10) ? "?" : $10
            acc = (NF < 11) ? "?" : (($11 == "") ? "-" : $11)
            rev = (NF < 13 || $13 == "") ? "-" : $13
            gn = (NF >= 14 && $14 == 1) ? 1 : 0
            # a part-of child of another thread inherits the parent thread qualification
            ps = ((NF >= 12 && $12 != "") ? secof[$12] : "") ; if (ps == "") ps = "-"
            print $1, sec, $3, $4, $5, $6, $7, $8, hub, qual, gotchas[hub] + 0, members[$1] + 0, acc, rev, gn, ps
        }' "$MANIFEST" "$MANIFEST")
fi

# 9. stale thread (graph mode, manifest): a section:threads node not edited in
# 14+ days is no longer "live" — the session-start render stops listing it
# (precis memory index --days). Advisory: it lists, it never changes anything.
if (( GRAPH )) && [[ -n "$MANIFEST" ]]; then
    stale_now=$(date +%s)
    while IFS=$'\t' read -r h _sec upd _rest; do
        [[ -n "$upd" ]] || continue
        upd_e=$(epoch_of "$upd" || true); [[ -n "$upd_e" ]] || continue
        age_d=$(( (stale_now - upd_e) / 86400 ))
        if (( age_d >= 14 )); then
            echo "  stale thread → $(label_of "$h.md" "$SCAN_DIR/$h.md") (${age_d}d untouched): retire, or move what matters to its docs/backlog/threads file / a gotcha / a todo"
            problems=$((problems+1))
        fi
    done < <(awk -F'\t' '$2 == "threads"' "$MANIFEST")
fi

if (( problems == 0 )) && (( GRAPH )); then
    echo "memory-lint: ✓ clean (graph mode, no hygiene findings)"
elif (( problems == 0 )); then
    echo "memory-lint: ✓ clean (links resolve, no landed threads lingering)"
else
    echo "memory-lint: $problems hygiene issue(s) above"
fi

# 7. retire candidates — LIST ONLY: never modifies anything, never touches
# $problems or the exit code. Heuristics (deliberately simple):
#   thread-type = frontmatter `type:` absent or `project`, AND (the file name or
#                 first-line title contains "thread"/"campaign", OR a body line
#                 starts with NEXT).
#   landed      = thread-type, >=1 resolvable cited sha, every resolvable sha in
#                 main (unresolvable hex tokens are words/cross-repo: ignored),
#                 and its NEXT line (if any) empty or done/shipped/none/nothing.
#   stale >30d  = frontmatter `modified:` date, else the manifest's updated
#                 date (graph mode), else file mtime.
# Graph-mode node bodies carry no frontmatter, so there the title/NEXT rules and
# the node's updated_at apply.
retire=0
if [[ -n "$SCAN_DIR" ]]; then
    now_e=$(date +%s)
    for tf in "$SCAN_DIR"/*.md; do
        b=$(basename "$tf")
        [[ -f "$tf" ]] && is_topic "$b" || continue
        reasons=()
        fm=$(awk 'NR==1 && $0!="---" { exit } NR>1 && $0=="---" { exit } NR>1' "$tf")
        ftype=$(printf '%s\n' "$fm" | sed -nE 's/^[[:space:]]*type:[[:space:]]*"?([A-Za-z_-]+).*/\1/p' | head -n 1)
        if [[ -z "$ftype" || "$ftype" == project ]]; then
            title=$(grep -m1 -E '^# ' "$tf" 2>/dev/null || true)
            nextline=$(grep -m1 -E '^[-*[:space:]]*NEXT\b' "$tf" || true)
            if printf '%s %s' "$b" "$title" | grep -qiE 'thread|campaign' || [[ -n "$nextline" ]]; then
                nexttext=$(printf '%s' "$nextline" | sed -E 's/^[-*[:space:]]*NEXT\b[^A-Za-z0-9]*//')
                if [[ -z "${nexttext//[[:space:]]/}" ]]; then
                    # bare `NEXT:` heading: open if a list item follows on the next non-blank line
                    nexttext=$(awk '/^[-*[:space:]]*NEXT([^A-Za-z0-9_]|$)/ { f=1; next } f && NF { print; exit }' "$tf" \
                        | sed -E 's/^[[:space:]]*([-*]|[0-9]+[.)])[[:space:]]+//' | grep -E '^[[:space:]]*[^[:space:]]' || true)
                fi
                if [[ -z "${nexttext//[[:space:]]/}" ]] || printf '%s' "$nexttext" | grep -qiE '^(done|shipped|none|nothing)\b'; then
                    n_res=0; n_out=0
                    while IFS= read -r s; do
                        [[ -z "$s" ]] && continue
                        case "$(sha_state "$s")" in 1) n_res=$((n_res+1));; 0) n_out=$((n_out+1));; esac
                    done < <(grep -ohiE '\b[0-9a-f]{7,40}\b' "$tf" | grep -iE '[a-f]' | sort -u || true)
                    (( n_res > 0 && n_out == 0 )) && reasons+=("thread landed ($n_res cited commit(s), all in main; NEXT empty/done)")
                fi
            fi
        fi
        mod=$(printf '%s\n' "$fm" | sed -nE 's/^[[:space:]]*modified:[[:space:]]*"?([0-9]{4}-[0-9]{2}-[0-9]{2}).*/\1/p' | head -n 1)
        mod_e=""
        [[ -z "$mod" && -n "$MANIFEST" ]] && mod=$(awk -F'\t' -v h="${b%.md}" '$1 == h { print $3; exit }' "$MANIFEST")
        [[ -n "$mod" ]] && mod_e=$(epoch_of "$mod" || true)
        [[ -z "$mod_e" ]] && mod_e=$(stat -c %Y "$tf" 2>/dev/null || stat -f %m "$tf" 2>/dev/null || echo "$now_e")
        age_d=$(( (now_e - mod_e) / 86400 ))
        (( age_d > 30 )) && reasons+=("stale >30d (${age_d}d)")
        if (( ${#reasons[@]} )); then
            rs=$(printf '%s; ' "${reasons[@]}"); rs="${rs%; }"
            echo "  retire candidate → $(label_of "$b" "$tf"): $rs"
            retire=$((retire+1))
        fi
    done
fi
echo "retire candidates: $retire (listed only; verify, then retire by hand)"

# 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 )) && [[ -z "$SCAN_DIR" ]]; then
    :   # graph mode, no node cache yet — the one-line note above already said so
elif (( 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 "$SCAN_DIR"/*.md; do
        b=$(basename "$tf")
        is_topic "$b" || 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.
        #     `[[slug]]` links are stripped first: a memory slug like
        #     [[worktree-switch-breaks-subagent-bash]] names a memory, not a branch.
        if (( has_open )) && (( ! has_landing )); then
            prose=$(sed -E 's/\[\[[^]]*\]\]//g' "$tf")
            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)?")
            # `|| true` on each: under set -e + pipefail a no-match grep ends the
            # subshell, so the worktree grep never ran when no "branch" matched.
            done < <( { grep -oiE 'branch +`?[a-z0-9][a-z0-9._-]+' <<< "$prose" | sed -E 's/^branch +`?//I' || true;
                        grep -oiE 'worktree-? *`?[a-z0-9][a-z0-9-]+' <<< "$prose" | sed -E 's/^worktree-? *`?//I' || true; } | 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 "  $(label_of "$b" "$tf"):"
            for f in "${findings[@]}"; do echo "    - $f"; done
        fi
    done
    if (( cur_suspect == 0 )); then
        echo "  ✓ all git+fs anchors verify"
    elif (( GRAPH )); then
        echo "  currency: $cur_suspect memory node(s) with stale anchors — adjust via edit(kind='memory', id=…) / kill via delete(kind='memory', id=…) / promote-to-doc (judgment pass)"
    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.
#
# In graph mode the log is the exported node titled memory_consolidation_log;
# the snapshot file in MEMDIR is frozen at the cutover, so reading it there
# would be exactly the frozen clock above.
today=$(date -u +%Y-%m-%d)
log_file="$MEMDIR/memory_consolidation_log.md"
if (( GRAPH )); then
    log_node=$(grep -lx '# memory_consolidation_log' "$SCAN_DIR"/*.md 2>/dev/null | head -1 || true)
    [[ -n "$log_node" ]] && log_file="$log_node"
fi
last=$(grep -oE '^[-#*[:space:]]*[0-9]{4}-[0-9]{2}-[0-9]{2}' "$log_file" 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"
elif (( GRAPH )); then
    echo "reconsolidation: DUE (last $last) — run 'scripts/memory-lint --currency' for the per-claim ledger over the memory nodes, then judgment-resolve each suspect (adjust via edit(kind='memory', id=…) / kill via delete(kind='memory', id=…) / promote-to-doc) + delete(kind='memory') any landed thread flagged above. Procedure: the reconsolidate skill; record the pass as a dated entry at the top of the memory_consolidation_log node"
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. 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
# 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 [[ -z "$SCAN_DIR" ]]; then
    :   # graph mode, no node cache yet — nothing scanned, so nothing to stamp
elif (( 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 "$SCAN_DIR"/*.md; do
        b=$(basename "$tf")
        is_topic "$b" || 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+=("$(ident "$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
