#!/usr/bin/env python3
"""Check and fix how much of a Claude Code auto-memory folder the agent can reach.

Claude Code loads only the top of MEMORY.md at session start: "the first 200 lines of
MEMORY.md, or the first 25KB, whichever comes first" (code.claude.com/docs/en/memory).
YAML frontmatter and block-level HTML comments are stripped before the index is loaded.
Other memory notes are read on demand. A note that the loaded index doesn't name, and
that no file named by the index names either, is more than two steps from anything a
fresh session reads.

    python3 memory_reach.py check   ~/.claude/projects/<project>/memory
    python3 memory_reach.py check   --all          # every ~/.claude/projects/*/memory
    python3 memory_reach.py archive ~/.claude/projects/<project>/memory           # preview
    python3 memory_reach.py archive ~/.claude/projects/<project>/memory --apply   # write

`check` exits 0 when every note is within two steps, 1 when some aren't, and 2 when a
folder can't be read. `archive` exits 2 whenever it refuses or fails.

`archive --apply` writes memory-archive-index.md (one line per note that the loaded index
doesn't name, with the note's description clipped to 140 characters) and keeps exactly one
"Archive tier" pointer line of its own near the top of MEMORY.md. MEMORY.md is edited
line by line on its raw bytes: only that pointer line is inserted, refreshed, moved into
view, or de-duplicated, and every other byte stays as it was. The one exception: if the
line just above the insert point has no line break, one is added. Every changed file gets
a uniquely named backup first; symlinks are followed and file modes kept, and a hard-linked
file is rewritten in place, with its old content put back if that write fails. An existing
archive file that this tool didn't generate is never replaced unless you pass --force
(backed up first). An archive path that is a folder, a broken symlink, or another name for
MEMORY.md or a note is left alone, with or without --force. A note whose filename contains
a line break can't be listed, so `archive` changes nothing until it is renamed.

Known limits:
- The load rules (200 lines, then 25,000 bytes; frontmatter and block-level HTML comments
  stripped; comments inside code fences kept; a line cut by the byte limit counts as not
  loaded) come from Claude Code's documentation. They were not checked against Claude
  Code's actual loader. Where the docs are vague, the script errs toward "not loaded".
- If a live session writes MEMORY.md while this runs, the run stops without writing
  MEMORY.md, though it may already have rewritten the archive file; without file locking,
  a write landing in the last few milliseconds before the replace can still be lost (it
  would not be in the backup either). Run it between sessions.
If you set autoMemoryDirectory, pass that folder. Standard library only; tests pass on
Python 3.10, 3.11 and 3.12.
"""
import bisect
import argparse
import os
import re
import shutil
import sys
import time
from pathlib import Path

MAX_LINES = 200
MAX_BYTES = 25_000
INDEX = "MEMORY.md"
ARCHIVE = "memory-archive-index.md"
BOM = b"\xef\xbb\xbf"
MARKER = b"<!-- generated by memory_reach.py -->"
POINTER_TAIL = ("not listed here, one line each; open it when an older topic, "
                "a past decision, or a note name comes up")
OURS = re.compile(rb"- \[Archive tier\]\(" + re.escape(ARCHIVE.encode()) + rb"\): \d+ older notes? "
                  + re.escape(POINTER_TAIL.encode()) + rb"[ \t]*\r?\n?\Z")
FRONTMATTER = re.compile(rb"\A(?:" + re.escape(BOM) + rb")?---[ \t]*\r?\n(?:.*?\r?\n)?---[ \t]*(?:\r?\n|\Z)", re.S)
ARCHIVE_LINK = re.compile(rb"\]\(<?" + re.escape(ARCHIVE.encode()) + rb">?\)")
ENTRY = re.compile(rb"^- \[(?:\\.|[^\]\\\r\n])*\]\((?:<((?:\\.|[^>\\\r\n])+)>|([^()\s]+))\)", re.M)
FILE_RE = re.compile(r"(?<![\w.\-+/:])(?:[\w.~\-]+/)*([\w.\-+]+\.md)(?![\w\-])")
WIKI_RE = re.compile(r"\[\[([^\[\]|#\r\n]+)(?:[|#][^\[\]\r\n]*)?\]\]")
SLUG_RE = re.compile(r"(?<![\w.\-/+])([\w\-]+)(?![\w\-+/]|\.\w)")
FENCE_OPEN = re.compile(rb"^ {0,3}(`{3,}|~{3,})([^\r\n]*)")
COMMENT_OPEN = re.compile(rb"^ {0,3}<!--")


# ------------------------------------------------------------- measuring

def split_lines(data: bytes) -> list:
    """Lines split on LF only, each keeping its own ending (CRLF stays CRLF)."""
    return re.findall(rb"[^\n]*\n|[^\n]+\Z", data)


def hidden_spans(raw: bytes) -> list:
    """Byte spans Claude Code strips before loading: leading frontmatter, and block-level
    HTML comments (a line starting with <!-- through the first --> that ends its line),
    skipping fenced code blocks."""
    spans, pos = [], 0
    fm = FRONTMATTER.match(raw)
    if fm:
        spans.append((0, fm.end()))
        pos = fm.end()
    fence = None  # (char, length) of the open code fence, if any
    while pos < len(raw):
        nl = raw.find(b"\n", pos)
        end = len(raw) if nl == -1 else nl + 1
        line = raw[pos:end].rstrip(b"\r\n")
        if pos == 0 and line.startswith(BOM):
            line = line[len(BOM):]
        m = FENCE_OPEN.match(line)
        if fence:
            if m and m.group(1)[:1] == fence[0] and len(m.group(1)) >= fence[1] and not m.group(2).strip():
                fence = None
        elif m and not (m.group(1)[:1] == b"`" and b"`" in m.group(2)):
            fence = (m.group(1)[:1], len(m.group(1)))
        elif COMMENT_OPEN.match(line):
            start = raw.find(b"<!--", pos)
            close = raw.find(b"-->", start + 4)
            if close != -1:
                close_nl = raw.find(b"\n", close)
                close_end = len(raw) if close_nl == -1 else close_nl + 1
                if not raw[close + 3:close_end].strip():
                    spans.append((pos, close_end))
                    pos = close_end
                    continue
        pos = end
    return spans


def visible_body(raw: bytes) -> bytes:
    out, pos = [], 0
    for a, b in hidden_spans(raw):
        out.append(raw[pos:a])
        pos = b
    out.append(raw[pos:])
    return b"".join(out)


def loaded(raw: bytes) -> bytes:
    """What a session loads: the visible body's first 200 lines, then its first 25,000
    bytes, dropping any line the byte limit cuts in half."""
    head = b"".join(split_lines(visible_body(raw))[:MAX_LINES])
    if len(head) > MAX_BYTES:
        head = head[:MAX_BYTES]
        head = head[: head.rfind(b"\n") + 1]
    return head


def text(data: bytes) -> str:
    return data.decode("utf-8", "surrogateescape")


def tokens(txt: str) -> tuple:
    """Every way a text can name a note, gathered in one pass."""
    files = set(FILE_RE.findall(txt))
    wikis = set(WIKI_RE.findall(txt))
    slugs = {s for s in SLUG_RE.findall(txt) if "-" in s or "_" in s}
    return files, wikis, slugs, txt


def names(path: Path, toks: tuple) -> bool:
    """True when the text names the note: its filename (bare or after a relative path),
    its [[wikilink]], or its bare slug when the slug is hyphenated or underscored.
    Plain words such as "todo" in prose don't count."""
    files, wikis, slugs, txt = toks
    if path.name in files or path.stem in wikis:
        return True
    if ("-" in path.stem or "_" in path.stem) and path.stem in slugs:
        return True
    if re.search(r"[^\w.\-+]", path.name):  # unusual names: spaces, brackets, ...
        return any(form in txt for form in (f"]({path.name})", f"]({link_target(path.name)})", f"`{path.name}`"))
    return False


def read(path: Path) -> bytes:
    try:
        return path.read_bytes()
    except OSError:
        return b""


def notes_in(store: Path) -> list:
    return sorted(p for p in store.glob("*.md") if p.is_file() and p.name not in (INDEX, ARCHIVE))


def archive_targets(data: bytes) -> set:
    return {text(re.sub(rb"\\(.)", rb"\1", a) if a else b) for a, b in ENTRY.findall(data)}


def link_target(name: str) -> str:
    if re.search(r"[\s()<>\\]", name):
        return "<" + re.sub(r"([<>\\])", r"\\\1", name) + ">"
    return name


class StoreError(Exception):
    """A folder this can't measure: no MEMORY.md, or one it can't read."""


def survey(store: Path) -> dict:
    index_path = store / INDEX
    try:
        if not index_path.is_file():
            raise StoreError(f"{index_path}: no MEMORY.md here (is this an auto-memory folder?)")
        raw = index_path.read_bytes()
    except OSError as e:
        raise StoreError(f"{index_path}: can't read it ({e.strerror or e})") from None
    head = loaded(raw)
    head_toks = tokens(text(head))
    notes = notes_in(store)
    named = [p for p in notes if names(p, head_toks)]
    named_set = set(named)
    hop_toks = tokens("\n".join(text(read(p)) for p in named))
    listed = archive_targets(read(store / ARCHIVE)) if ARCHIVE_LINK.search(head) else set()
    direct, one_hop, via_archive, unreachable = [], [], [], []
    for p in notes:
        if p in named_set:
            direct.append(p)
        elif names(p, hop_toks):
            one_hop.append(p)
        elif p.name in listed:
            via_archive.append(p)
        else:
            unreachable.append(p)
    body = visible_body(raw)
    return {"raw": raw, "notes": notes, "direct": direct, "one_hop": one_hop,
            "via_archive": via_archive, "unreachable": unreachable,
            "lines": len(split_lines(body)), "bytes": len(body), "loaded_lines": len(split_lines(head))}


def pct(part: int, whole: int) -> int:
    return round(100 * part / whole) if whole else 0


def cmd_check(stores: list, show: int) -> int:
    worst, failed = 0, False
    for store in stores:
        try:
            s = survey(store)
        except StoreError as e:
            print(e, file=sys.stderr)
            failed = True
            continue
        n, u = len(s["notes"]), len(s["unreachable"])
        print(store)
        print(f"  MEMORY.md (as measured): {s['lines']} lines, {s['bytes']:,} bytes; "
              f"{s['lines'] - s['loaded_lines']} line(s) past the load cut ({MAX_LINES} lines / {MAX_BYTES:,} bytes)")
        print(f"  notes: {n}   named in the loaded index: {len(s['direct'])}   "
              f"named by a file the index names: {len(s['one_hop'])}   listed in the archive tier: {len(s['via_archive'])}")
        print(f"  NOT within two steps of the loaded index: {u} ({pct(u, n)}% of notes)")
        for p in s["unreachable"][:show]:
            print(f"    - {p.name}")
        if show and u > show:
            print(f"    … and {u - show} more")
        worst = max(worst, u)
    return 2 if failed else (1 if worst else 0)


# ------------------------------------------------------------- descriptions

ESCAPES = {"t": "\t", "n": " ", "\\": "\\", '"': '"', "/": "/", "0": ""}


def unescape_double(s: str) -> str:
    def sub(m):
        e = m.group(1)
        if e[0] in ("u", "x", "U"):
            try:
                return chr(int(e[1:], 16))
            except ValueError:
                return m.group(0)
        return ESCAPES.get(e, e)
    return re.sub(r"\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{2}|.)", sub, s)


def yaml_scalar(value: str, rest: list) -> str:
    """A best-effort YAML scalar: plain (possibly continued), quoted, or block (> |)."""
    cont = []
    for line in rest:
        if line[:1] in (" ", "\t") or not line.strip():
            cont.append(line.strip())
        else:
            break
    if value in ("", ">", ">-", ">+", "|", "|-", "|+"):
        return " ".join(c for c in cont if c)
    if value[0] == '"':
        buf = " ".join([value[1:]] + [c for c in cont if c])
        end = re.search(r'(?<!\\)(?:\\\\)*"', buf)
        return unescape_double(buf[: end.end() - 1] if end else buf)
    if value[0] == "'":
        buf = " ".join([value[1:]] + [c for c in cont if c])
        m = re.match(r"((?:[^']|'')*)'", buf)
        return (m.group(1) if m else buf).replace("''", "'")
    plain = " ".join([value] + [c for c in cont if c])
    return re.split(r"\s#", plain, maxsplit=1)[0].strip()


def description(path: Path, limit: int = 140) -> str:
    """The note's frontmatter `description:`, else its first prose line, clipped."""
    lines = text(read(path)).lstrip("﻿").splitlines()
    body = lines
    if lines and lines[0].strip() == "---":
        end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
        if end is not None:
            fm, body = lines[1:end], lines[end + 1:]
            for i, line in enumerate(fm):
                m = re.match(r"description:\s*(.*)$", line)
                if m:
                    desc = yaml_scalar(m.group(1).strip(), fm[i + 1:])
                    if desc:
                        return clip(desc, limit)
    for line in body:
        if line.strip() and not line.lstrip().startswith(("#", "---", "<!--")):
            return clip(line.strip(), limit)
    return ""


def clip(s: str, limit: int) -> str:
    s = " ".join(s.split())
    if len(s) <= limit:
        return s
    return s[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"


# ------------------------------------------------------------- the pointer line

def pointer_line(count: int, nl: bytes) -> bytes:
    noun = "note" if count == 1 else "notes"
    return f"- [Archive tier]({ARCHIVE}): {count} older {noun} {POINTER_TAIL}".encode() + nl


def ending(line: bytes) -> bytes:
    return b"\r\n" if line.endswith(b"\r\n") else (b"\n" if line.endswith(b"\n") else b"")


def our_lines(raw: bytes) -> tuple:
    """(lines, indexes of our pointer lines that are not inside frontmatter or a comment)."""
    lines = split_lines(raw)
    starts = [a for a, _ in hidden_spans(raw)]
    ends = [b for _, b in hidden_spans(raw)]
    ours, pos = [], 0
    for i, l in enumerate(lines):
        k = bisect.bisect_right(starts, pos) - 1
        hidden = k >= 0 and pos < ends[k]
        if not hidden and OURS.match(l[len(BOM):] if i == 0 and l.startswith(BOM) else l):
            ours.append(i)
        pos += len(l)
    return lines, ours


def visible_pointer(data: bytes) -> bool:
    return any(OURS.match(l) for l in split_lines(loaded(data)))


def insert_pointer(lines: list, at: int, count: int) -> bytes:
    lines = list(lines)
    if at > 0:
        nl = ending(lines[at - 1])
        if not nl:
            nl = ending(lines[0]) or b"\n"
            lines[at - 1] += nl
        lines.insert(at, pointer_line(count, nl))
    else:
        bom = bool(lines) and lines[0].startswith(BOM)
        if bom:
            lines[0] = lines[0][len(BOM):]
        nl = (ending(lines[0]) if lines else b"") or b"\n"
        lines.insert(0, (BOM if bom else b"") + pointer_line(count, nl))
    return b"".join(lines)


def with_pointer(raw: bytes, count: int) -> bytes:
    """Keep exactly one of our pointer lines, in view near the top; touch nothing else."""
    lines, ours = our_lines(raw)
    bom = raw.startswith(BOM)
    head = loaded(raw)
    head = head[len(BOM):] if head.startswith(BOM) else head
    keep = next((i for i in ours if re.search(rb"(?m)^" + re.escape(lines[i].lstrip(BOM).rstrip(b"\r\n"))
                                              + rb"[ \t]*\r?$", head)), None)
    if keep is not None:
        refreshed = list(lines)
        bom = BOM if keep == 0 and refreshed[0].startswith(BOM) else b""
        refreshed[keep] = bom + pointer_line(count, ending(refreshed[keep]))
        for i in reversed([i for i in ours if i != keep]):
            del refreshed[i]
        result = b"".join(refreshed)
        if visible_pointer(result):
            return result
        ours = sorted(set(ours) | {keep})
    for i in reversed(ours):
        del lines[i]
    if bom and not (lines and lines[0].startswith(BOM)):
        lines = [BOM + lines[0]] + lines[1:] if lines else [BOM]  # the BOM rode on a deleted line
    # Insert after frontmatter, a first heading, and one blank line, when present.
    at = 0
    fm = FRONTMATTER.match(b"".join(lines))
    if fm:
        consumed = 0
        while at < len(lines) and consumed < fm.end():
            consumed += len(lines[at])
            at += 1
    after_fm = at
    if at < len(lines) and lines[at].lstrip(BOM).startswith(b"#"):
        at += 1
        if at < len(lines) and not lines[at].strip():
            at += 1
    result = insert_pointer(lines, at, count)
    if at != after_fm and not visible_pointer(result):
        result = insert_pointer(lines, after_fm, count)  # e.g. a heading longer than the byte limit
    return result


# ------------------------------------------------------------- writing safely

def unique_backup(path: Path, stamp: str) -> Path:
    """Copy `path` to a backup name that does not exist yet (exclusive create)."""
    n = 0
    while True:
        dest = path.with_name(f"{path.name}.bak-{stamp}" + (f"-{n}" if n else ""))
        try:
            with open(dest, "xb") as out, open(path, "rb") as src:
                shutil.copyfileobj(src, out)
            shutil.copystat(path, dest)
            return dest
        except FileExistsError:
            n += 1


def write_tmp(dest: Path, data: bytes) -> Path:
    tmp = dest.with_name(f".{dest.name}.tmp-{os.getpid()}")
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
    except BaseException:
        try:
            tmp.unlink()
        except OSError:
            pass
        raise
    return tmp


class PartlyWritten(OSError):
    """A write in place failed, and so did putting the old content back."""


def put(f, data: bytes) -> None:
    f.seek(0)
    view = memoryview(data)
    while view:
        view = view[f.write(view):]
    f.truncate(len(data))


def write_file(path: Path, data: bytes) -> None:
    """Replace the content of an existing file: through symlinks, keeping its mode; in place
    for hard-linked files, putting the old content back if that write fails; otherwise
    atomically via a temp file next to the real file."""
    target = path.resolve()
    if target.stat().st_nlink > 1:
        with open(target, "r+b", buffering=0) as f:
            old = f.read()
            try:
                put(f, data)
            except BaseException as e:
                try:
                    put(f, old)
                except OSError:
                    raise PartlyWritten(f"{target.name} may be partly written") from e
                raise
        return
    tmp = write_tmp(target, data)
    try:
        shutil.copymode(target, tmp)
        os.replace(tmp, target)
    finally:
        if tmp.exists():
            tmp.unlink()


def write_new(path: Path, data: bytes) -> None:
    tmp = write_tmp(path, data)
    try:
        os.chmod(tmp, 0o644 & ~current_umask())
        os.replace(tmp, path)
    finally:
        if tmp.exists():
            tmp.unlink()


def current_umask() -> int:
    mask = os.umask(0)
    os.umask(mask)
    return mask


def same_file(path: Path, others: list):
    """The first of `others` that is the same file as `path`, through symlinks or hard links."""
    try:
        st = path.stat()
    except OSError:
        return None
    for other in others:
        try:
            if os.path.samestat(st, other.stat()):
                return other
        except OSError:
            continue
    return None


# ------------------------------------------------------------- archive

def cmd_archive(store: Path, apply: bool, force: bool) -> int:
    s = survey(store)
    raw = s["raw"]
    index_path, archive_path = store / INDEX, store / ARCHIVE
    if (archive_path.exists() or archive_path.is_symlink()) and not archive_path.is_file():
        print(f"{archive_path} exists and is not a regular file or a link to one; nothing changed",
              file=sys.stderr)
        return 2
    twin = same_file(archive_path, [index_path] + s["notes"])
    if twin is not None:
        print(f"{archive_path} is another name for {twin.name}; refusing to replace it, nothing changed",
              file=sys.stderr)
        return 2
    existing = read(archive_path) if archive_path.is_file() else None
    if existing is not None and not existing.startswith(MARKER) and not force:
        print(f"{archive_path} exists and was not generated by memory_reach.py; refusing to replace it.\n"
              "  Re-run with --force to replace it (a backup is kept).", file=sys.stderr)
        return 2
    # Size the tier against the longest pointer this run could write, so the real (never
    # longer) pointer cannot push a note it relies on past the load cut.
    worst = with_pointer(raw, max(len(s["notes"]), 2))
    worst_toks = tokens(text(b"".join(l for l in split_lines(loaded(worst)) if not OURS.match(l))))
    tier = [p for p in s["notes"] if not names(p, worst_toks)]
    unlistable = [p for p in tier if re.search(r"[\r\n]", p.name)]
    if unlistable:
        for p in unlistable:
            print(f"  {p.name!r} has a line break in its filename, so it can't be listed", file=sys.stderr)
        print(f"{store}: rename {'that note' if len(unlistable) == 1 else 'those notes'} and run it again; "
              "nothing changed", file=sys.stderr)
        return 2
    has_ours = bool(our_lines(raw)[1])
    if not tier and not has_ours:
        print(f"{store}: every note is named in the loaded index; nothing to archive")
        return 0
    new_index = with_pointer(raw, len(tier))

    def mtime(p):
        try:
            return p.stat().st_mtime
        except OSError:
            return 0.0

    tier.sort(key=lambda p: (-mtime(p), p.name))
    entries = []
    for p in tier:
        label = p.stem.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
        desc = description(p)
        entries.append(f"- [{label}]({link_target(p.name)})" + (f": {desc}" if desc else ""))
    noun = "note" if len(entries) == 1 else "notes"
    archive = (MARKER + b"\n# Memory archive index\n\n"
               + (f"Every memory note that MEMORY.md does not list, newest first ({len(entries)} {noun}). "
                  "Regenerate with `python3 memory_reach.py archive <folder> --apply`; the notes "
                  "themselves are never changed.\n\n").encode()
               + "\n".join(entries).encode("utf-8", "surrogateescape") + b"\n")
    change_index, change_archive = new_index != raw, archive != existing
    print(f"{store}: {len(entries)} {noun} for {ARCHIVE}; MEMORY.md "
          + ("pointer line updated" if change_index else "unchanged"))
    index_real, archive_real = index_path.resolve(), archive_path.resolve()
    writable = (os.access(store, os.W_OK)
                and (not change_index or (os.access(index_real, os.W_OK) and os.access(index_real.parent, os.W_OK)))
                and (not change_archive or os.access(archive_real.parent, os.W_OK)))
    if not apply:
        print("  preview only; re-run with --apply to write" + ("" if writable else " (folder or MEMORY.md is not writable)"))
        for e in entries[:5]:
            print("  " + e[:150])
        return 0
    if not (change_index or change_archive):
        print("  already up to date")
        return 0
    if not writable:
        print(f"{store}: not writable; nothing changed", file=sys.stderr)
        return 2
    if index_path.read_bytes() != raw:
        print(f"{index_path} changed while this ran; nothing written. Run it again.", file=sys.stderr)
        return 2
    stamp = time.strftime("%Y%m%d-%H%M%S")
    made = []
    try:
        if change_archive and existing is not None:
            made.append(unique_backup(archive_path, stamp))
        if change_index:
            made.append(unique_backup(index_path, stamp))
        if change_archive:
            if existing is not None:
                write_file(archive_path, archive)  # ours, or forced: keep its links and mode
            else:
                write_new(archive_path, archive)
        if change_index:
            if index_path.read_bytes() != raw:
                print(f"{index_path} changed while this ran; it was not written "
                      "(the archive was). Run it again.", file=sys.stderr)
                return 2
            write_file(index_path, new_index)
    except PartlyWritten as e:
        print(f"write failed: {e.__cause__}. {e}; restore it from its backup: "
              + ", ".join(p.name for p in made), file=sys.stderr)
        return 2
    except OSError as e:
        print(f"write failed: {e}. MEMORY.md is unchanged unless reported above; backups: "
              + ", ".join(p.name for p in made), file=sys.stderr)
        return 2
    after = survey(store)
    print(f"  written; backups: {', '.join(p.name for p in made) or 'none needed'}. "
          f"Not within two steps now: {len(after['unreachable'])}")
    return 0


def main(argv=None) -> int:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(errors="backslashreplace")
        sys.stderr.reconfigure(errors="backslashreplace")
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    sub = ap.add_subparsers(dest="cmd", required=True)
    c = sub.add_parser("check", help="report how many notes are within two steps of the loaded index")
    c.add_argument("store", nargs="?", type=Path)
    c.add_argument("--all", action="store_true", help="every ~/.claude/projects/*/memory with a MEMORY.md")
    c.add_argument("--show", type=int, default=10, help="how many unreachable notes to list")
    a = sub.add_parser("archive", help="build the archive tier (preview unless --apply)")
    a.add_argument("store", type=Path)
    a.add_argument("--apply", action="store_true")
    a.add_argument("--force", action="store_true", help="replace an archive file this tool didn't generate")
    args = ap.parse_args(argv)
    if args.cmd == "check":
        if args.all:
            stores = sorted(p for p in (Path.home() / ".claude/projects").glob("*/memory") if (p / INDEX).is_file())
        elif args.store:
            stores = [args.store.expanduser()]
        else:
            ap.error("check needs a memory folder or --all")
        return cmd_check(stores, args.show)
    try:
        return cmd_archive(args.store.expanduser(), args.apply, args.force)
    except StoreError as e:
        print(e, file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())
