#!/usr/bin/env python3
"""notice — small stdlib CLI for Mission Control Notice Board."""
from __future__ import annotations
import argparse, contextlib, fcntl, json, math, os, re, subprocess, sys, tempfile, time
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode

REPO = Path(__file__).resolve().parent.parent
BRAIN = REPO / 'brain.json'
INBOX = REPO / 'inbox.md'
RENDER = REPO / 'render.py'
VALIDATOR = REPO / 'scripts' / 'validate_brain.py'
LOCK_NAME = '.notice-board.lock'
LOCK_TIMEOUT_SECONDS = 30.0

CATEGORIES = (
    "Learning", "Research", "Read", "Personal Admin",
    "Ideas", "Dab Improvements", "Uncategorized",
)
LEGACY_CATEGORY_MAP = {
    "Research / Read": "Research",
}
STATUSES = ("Inbox", "Explore", "Done")
# v3.0 outcomes — `graduated` (idea→project) and `read` (link consumed)
# join the existing v2.1 set.
OUTCOMES = ("completed", "dropped", "stale", "archived", "graduated", "read")
LINK_STATUSES = ("unexplored", "explainer_ready", "read", "archived", "dropped")
TYPES = ("link", "idea", "learning", "project", "admin", "note")
# Category → default v3.0 type, used when a capture doesn't specify one.
CATEGORY_TO_TYPE = {
    "Read":             "link",
    "Ideas":            "idea",
    "Research":         "learning",
    "Learning":         "learning",
    "Personal Admin":   "admin",
    "Dab Improvements": "admin",
    "Uncategorized":    "note",
}
TYPE_TO_CATEGORY = {
    "link":     "Read",
    "idea":     "Ideas",
    "learning": "Research",
    "project":  "Ideas",
    "admin":    "Personal Admin",
    "note":     "Uncategorized",
}
# Option A migration: archived/explainer_ready replace `promoted`. The
# simplified Link Triage drops `needs_review` — it merges back into
# `unexplored`.
LEGACY_LINK_STATUSES = {"promoted", "needs_review"}
REL_TYPES = ("parent_of", "child_of", "forked_from", "related_to", "merged_into", "artifact_of")


def now():
    return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')


def today():
    return datetime.now(timezone.utc).strftime('%Y-%m-%d')


EXPLAINER_BACKLINK_HTML = '''\n  <a class="nb-backlink" href="./" style="display:inline-block;margin:16px 0 0 16px;color:#ffcf75;text-decoration:none;font:600 0.95rem system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">← Item page</a>\n'''


def _explainer_has_backlink(html: str) -> bool:
    """Return true when a standalone explainer can navigate back to its item page."""
    return (
        'href="./"' in html
        or "href='./'" in html
        or '← Item page' in html
        or 'Back to item' in html
    )


def ensure_explainer_backlink(path: Path) -> bool:
    """Inject a compact item-page backlink into explainer HTML if it is missing.

    The backlink is intentionally inline-styled so it works across the varied
    hand-authored/agent-authored standalone explainer styles.
    """
    try:
        html = path.read_text(encoding='utf-8')
    except UnicodeDecodeError:
        return False
    if _explainer_has_backlink(html):
        return False
    lower = html.lower()
    idx = lower.find('<body')
    if idx == -1:
        return False
    close = html.find('>', idx)
    if close == -1:
        return False
    updated = html[:close + 1] + EXPLAINER_BACKLINK_HTML + html[close + 1:]
    path.write_text(updated, encoding='utf-8')
    return True


# ---------------------------------------------------------------------------
# v3.0 helpers — phase mirrors status, sources is non-empty, every mutation
# bumps ``last_modified_at``.
# ---------------------------------------------------------------------------

def lc_phase(status: str | None) -> str:
    s = (status or "Inbox").strip().lower()
    return s if s in ("inbox", "explore", "done") else "inbox"


def stamp_modified(item: dict) -> None:
    """Bump v3.0 last_modified_at + v2.1 updated_at in one place."""
    ts = now()
    item['updated_at'] = ts
    item['last_modified_at'] = ts


def append_source(item: dict, via: str, raw: str | None = None) -> None:
    """Record a new entry on ``item.sources`` (v3.0 audit list).

    No-op if an identical (via, raw) pair already sits at the tail —
    avoids ballooning the list on duplicate re-captures.
    """
    sources = item.setdefault('sources', [])
    entry = {'via': via or 'unknown', 'raw': raw or item.get('title', ''), 'at': now()}
    if sources and sources[-1].get('via') == entry['via'] and sources[-1].get('raw') == entry['raw']:
        return
    sources.append(entry)


def ensure_v3_fields(item: dict, *, default_via: str = 'legacy') -> dict:
    """Backfill any missing v3.0 fields on an item dict.

    Items minted before plan 03 may still lack these fields on the
    transition path; ensure they're present before validation. Plan 04
    adds ``folder_path`` (every item owns ``items/<slug>/``).
    """
    item.setdefault('type', CATEGORY_TO_TYPE.get(item.get('category'), 'note'))
    item['phase'] = lc_phase(item.get('status'))
    if not item.get('last_modified_at'):
        item['last_modified_at'] = item.get('updated_at') or item.get('created_at') or now()
    if not item.get('sources'):
        item['sources'] = [{
            'via': item.get('source') or default_via,
            'raw': item.get('title') or '',
            'at': item.get('created_at') or now(),
        }]
    item.setdefault('tags', [])
    slug = item.get('slug')
    if slug:
        item['folder_path'] = f"items/{slug}/"
    return item


def ensure_item_folder(slug: str) -> Path:
    """Create ``items/<slug>/`` if missing and return the Path.

    Capture commands call this so a freshly-minted item always has its
    folder on disk, even before the next ``render.py`` run.
    """
    folder = REPO / 'items' / slug
    folder.mkdir(parents=True, exist_ok=True)
    return folder


def load():
    return json.loads(BRAIN.read_text(encoding='utf-8'))


def save(b):
    """Direct write — DO NOT call from mutating commands. Use ``transaction``.

    Retained as a low-level building block for transaction() and any one-off
    administrative script that already holds the lock.
    """
    b['updated_at'] = now()
    BRAIN.write_text(json.dumps(b, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')


# ---------------------------------------------------------------------------
# Cross-process write safety: lock + atomic write + validate + audit.
# ---------------------------------------------------------------------------

_validate_brain_fn = None


def _load_validator():
    """Import scripts.validate_brain.validate_brain on demand (stdlib-only)."""
    global _validate_brain_fn
    if _validate_brain_fn is None:
        scripts_dir = str(REPO / 'scripts')
        if scripts_dir not in sys.path:
            sys.path.insert(0, scripts_dir)
        from validate_brain import validate_brain as vb  # type: ignore
        _validate_brain_fn = vb
    return _validate_brain_fn


@contextlib.contextmanager
def _file_lock(timeout: float = LOCK_TIMEOUT_SECONDS):
    """Cross-process exclusive lock via fcntl.flock on ``.notice-board.lock``.

    The lock file sits next to ``brain.json`` so test sandboxes (which override
    ``BRAIN``) get their own lock and do not contend with the live CLI.

    Linux ``flock`` releases automatically on process death, so stale lock
    files left behind by a crash do not need cleanup. We poll non-blockingly
    so we can enforce a soft timeout instead of hanging forever.
    """
    lock_path = BRAIN.parent / LOCK_NAME
    fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o644)
    try:
        deadline = time.monotonic() + timeout
        while True:
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except BlockingIOError:
                if time.monotonic() >= deadline:
                    raise SystemExit(
                        f"notice: could not acquire {lock_path} within {timeout:.0f}s; "
                        "another notice CLI write is in progress"
                    )
                time.sleep(0.05)
        yield
    finally:
        try:
            fcntl.flock(fd, fcntl.LOCK_UN)
        finally:
            os.close(fd)


def _atomic_write_text(path: Path, data: str) -> None:
    """Write ``data`` to ``path`` atomically (write tmp, fsync, os.replace)."""
    parent = path.parent
    parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_name = tempfile.mkstemp(prefix=path.name + '.', suffix='.tmp', dir=str(parent))
    try:
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_name, str(path))
    except BaseException:
        try:
            os.unlink(tmp_name)
        except OSError:
            pass
        raise


def transaction(action, mutate, *, timeout: float = LOCK_TIMEOUT_SECONDS):
    """Run a single read → mutate → stage-audit → validate → atomic-write cycle.

    Inside the cross-process lock:

      1. Read a fresh brain.json so waiters see prior committed writes.
      2. Read the current inbox.md text (or '' if absent).
      3. Invoke ``mutate(brain)`` to apply the change in memory.
      4. Stage the audit append in memory — do NOT touch inbox.md yet.
      5. Validate the staged brain dict via
         ``scripts.validate_brain.validate_brain``. On any failure, abort
         BEFORE writing either file, so brain.json and inbox.md remain
         byte-identical to their pre-transaction state.
      6. Atomically replace brain.json with the new text.
      7. Atomically replace inbox.md with the staged text.

    ``mutate`` may return:
        - None              : no audit entry, no caller payload
        - str detail        : audit detail string, no payload
        - (detail, payload) : audit detail + payload returned to caller

    Two-file atomicity caveat: POSIX gives us atomic single-file
    ``rename()`` via ``os.replace``, not multi-file atomic commit. If the
    process is killed between steps 6 and 7, brain.json may be updated
    while inbox.md is not. The reverse (inbox without brain) is impossible
    because inbox is written last. This is acceptable for an
    append-only audit log: missing one trailing line is recoverable from
    the brain.json diff, while a phantom inbox entry for a write that
    never happened would be worse. Validation failures — the common
    failure mode — never write either file.
    """
    validator = _load_validator()
    with _file_lock(timeout):
        brain = json.loads(BRAIN.read_text(encoding='utf-8'))
        try:
            existing_inbox = INBOX.read_text(encoding='utf-8')
        except FileNotFoundError:
            existing_inbox = ''
        result = mutate(brain)
        if isinstance(result, tuple) and len(result) == 2:
            detail, payload = result
        else:
            detail, payload = result, None
        brain['updated_at'] = now()
        # Schema validation only (root=None) — project_file existence is
        # checked by `notice validate`, not on every mutation, to keep
        # sandboxed test runs honest.
        errs = validator(brain, root=None)
        if errs:
            msg = "notice: transaction aborted — brain.json validation failed:\n  - " + "\n  - ".join(errs)
            raise SystemExit(msg)
        new_inbox = existing_inbox
        if detail:
            stamp = now()
            new_inbox = existing_inbox + (
                f"\n## {stamp} — notice CLI · {action}\n\n- {detail}\n"
            )
        _atomic_write_text(
            BRAIN,
            json.dumps(brain, indent=2, ensure_ascii=False) + '\n',
        )
        if new_inbox != existing_inbox:
            _atomic_write_text(INBOX, new_inbox)
        return payload


def slug(s):
    return re.sub(r'[^a-zA-Z0-9]+', '-', s.strip().lower()).strip('-')[:60] or 'item'


def find(b, i):
    return next((x for x in b.get('items', []) if x.get('display_id') == i), None)


def find_by_slug(b, s):
    return next((x for x in b.get('items', []) if x.get('slug') == s), None)


def audit(action, detail):
    with INBOX.open('a', encoding='utf-8') as f:
        f.write(f"\n## {now()} — notice CLI · {action}\n\n- {detail}\n")


def _resolve_category(raw: str) -> str:
    """Tolerate legacy 'Research / Read' input by silently migrating to Research."""
    if raw in CATEGORIES:
        return raw
    if raw in LEGACY_CATEGORY_MAP:
        new = LEGACY_CATEGORY_MAP[raw]
        print(f"note: '{raw}' is a legacy category; recording as '{new}'", file=sys.stderr)
        return new
    raise SystemExit(f"unknown category: {raw}. allowed: {', '.join(CATEGORIES)}")


def _resolve_link_status(raw: str | None) -> str:
    """Map legacy 'promoted' onto Option A statuses with a warning."""
    if raw is None:
        return "unexplored"
    if raw == "needs_review":
        print(
            "note: 'needs_review' is no longer a Link Triage lane; "
            "recording as 'unexplored'. Use 'explainer_ready' / 'read' / "
            "'archived' / 'dropped' explicitly when relevant.",
            file=sys.stderr,
        )
        return "unexplored"
    if raw in LEGACY_LINK_STATUSES:
        print(
            f"note: '{raw}' is a legacy link status (Option A); recording as 'archived'. "
            "Use 'explainer_ready' or 'read' explicitly when relevant.",
            file=sys.stderr,
        )
        return "archived"
    if raw not in LINK_STATUSES:
        raise SystemExit(f"unknown link status: {raw}. allowed: {', '.join(LINK_STATUSES)}")
    return raw


_URL_RE = re.compile(r"^https?://", re.IGNORECASE)


def _looks_like_url(s: str | None) -> bool:
    return bool(s and _URL_RE.match(s.strip()))


_DROP_PARAMS = frozenset({
    "s", "ref", "ref_src", "context",
    "utm_source", "utm_medium", "utm_campaign",
    "utm_term", "utm_content", "utm_id",
    "fbclid", "gclid", "mc_cid", "mc_eid",
})


def canonicalize_url(url: str) -> str:
    """Return a canonical form of url for dedup. Strips tracking params,
    lowercases scheme + host, drops fragment, normalizes trailing slash."""
    if not url:
        return url
    try:
        p = urlparse(url.strip())
    except Exception:
        return url.strip()
    scheme = (p.scheme or "https").lower()
    netloc = p.netloc.lower()
    path = p.path or "/"
    if len(path) > 1 and path.endswith("/"):
        path = path[:-1]
    query_pairs = [(k, v) for (k, v) in parse_qsl(p.query, keep_blank_values=False)
                   if k.lower() not in _DROP_PARAMS]
    query = urlencode(sorted(query_pairs))
    return urlunparse((scheme, netloc, path, p.params, query, ""))


# ---------------------------------------------------------------------------
# Plan 07 — TF-IDF auto-connect.
#
# A pure-stdlib similarity matcher over v3 items. We deliberately keep the
# implementation small (Counter + math.log) so the Pi 4 can run it in well
# under 100 ms on the current ~75-item corpus.
# ---------------------------------------------------------------------------

# Suggestion thresholds and caps. Tuned for the current ~75-item corpus.
AUTO_CONNECT_THRESHOLD = 0.75
AUTO_CONNECT_TOP_K = 3
AUTO_CONNECT_PRUNE_DAYS = 30

_TOKEN_RE = re.compile(r"[A-Za-z0-9]{3,}")
_STOPWORDS = frozenset({
    'the', 'and', 'for', 'with', 'into', 'from', 'this', 'that', 'http',
    'https', 'www', 'com', 'org', 'net', 'have', 'has', 'are', 'was',
    'were', 'they', 'their', 'there', 'will', 'would', 'should', 'could',
    'about', 'which', 'what', 'when', 'where', 'who', 'how', 'why', 'all',
    'any', 'some', 'one', 'two', 'not', 'but', 'just', 'like', 'over',
    'because', 'than', 'too', 'very', 'can', 'may', 'might', 'must',
    'item', 'note', 'notes', 'capture',
})


def _tokens(text: str) -> list[str]:
    if not text:
        return []
    return [t.lower() for t in _TOKEN_RE.findall(text)
            if t.lower() not in _STOPWORDS]


def _corpus_for(item: dict) -> str:
    """Build the matching corpus for an item.

    Plan 7 spec: title + first 500 chars of notes/explainer + tags.
    Also includes ``template_fields`` string values so each per-type
    template's primary text (premise, goal, action, etc.) participates.
    """
    parts: list[str] = []
    title = item.get('title') or ''
    parts.append(title)
    parts.extend(item.get('tags') or [])
    # Phase notes — capture text + explore intent + done summary.
    phases = item.get('phases') or {}
    if isinstance(phases, dict):
        inbox_notes = (phases.get('inbox') or {}).get('notes') or []
        for n in inbox_notes:
            if isinstance(n, str):
                parts.append(n)
        raw = (phases.get('inbox') or {}).get('raw_capture')
        if isinstance(raw, str):
            parts.append(raw)
        explore = phases.get('explore') or {}
        for k in ('intent', 'next_action'):
            v = explore.get(k)
            if isinstance(v, str):
                parts.append(v)
        for q in explore.get('open_questions') or []:
            if isinstance(q, str):
                parts.append(q)
        done = phases.get('done') or {}
        for k in ('summary',):
            v = done.get(k)
            if isinstance(v, str):
                parts.append(v)
    # Embedded link explainer text — the cron explainer pass writes a short
    # markdown blurb here that's gold for matching.
    link = item.get('link')
    if isinstance(link, dict):
        for k in ('explainer_md', 'domain'):
            v = link.get(k)
            if isinstance(v, str):
                parts.append(v)
    # Per-type template fields — string and list-of-string values.
    tf = item.get('template_fields') or {}
    if isinstance(tf, dict):
        for v in tf.values():
            if isinstance(v, str):
                parts.append(v)
            elif isinstance(v, list):
                for x in v:
                    if isinstance(x, str):
                        parts.append(x)
    blob = ' '.join(p for p in parts if p)
    # Cap to the first 500 chars of the notes/explainer corpus so very long
    # items don't dominate.  We always keep the title (already added first).
    if len(blob) > 2000:
        blob = blob[:2000]
    return blob


def _build_index(items: list[dict]) -> tuple[list[tuple[str, dict[str, float]]], dict[str, dict[str, float]]]:
    """Return (ordered_index, by_slug) of TF-IDF vectors.

    Each vector is a sparse dict[token -> weight] so cosine is cheap.
    """
    docs: list[tuple[str, list[str]]] = []
    for it in items:
        slug = it.get('slug')
        if not isinstance(slug, str) or not slug:
            continue
        docs.append((slug, _tokens(_corpus_for(it))))
    df: Counter[str] = Counter()
    for _, toks in docs:
        for t in set(toks):
            df[t] += 1
    N = len(docs) or 1
    index: list[tuple[str, dict[str, float]]] = []
    by_slug: dict[str, dict[str, float]] = {}
    for slug, toks in docs:
        if not toks:
            v: dict[str, float] = {}
        else:
            tf = Counter(toks)
            v = {}
            length = max(len(toks), 1)
            for t, n in tf.items():
                idf = math.log((1 + N) / (1 + df[t])) + 1
                v[t] = (n / length) * idf
        index.append((slug, v))
        by_slug[slug] = v
    return index, by_slug


def _cosine(a: dict[str, float], b: dict[str, float]) -> float:
    if not a or not b:
        return 0.0
    common = set(a) & set(b)
    if not common:
        return 0.0
    num = 0.0
    for t in common:
        num += a[t] * b[t]
    na = math.sqrt(sum(x * x for x in a.values()))
    nb = math.sqrt(sum(x * x for x in b.values()))
    if not na or not nb:
        return 0.0
    return num / (na * nb)


def suggest_relationships(brain: dict, target_slug: str,
                          *, threshold: float = AUTO_CONNECT_THRESHOLD,
                          top_k: int = AUTO_CONNECT_TOP_K) -> list[dict]:
    """Compute the top-K similar items to ``target_slug`` from brain.items.

    Returns a list of ``{"to_slug": str, "score": float}`` dicts sorted by
    descending score. Already-confirmed relationships are excluded so we
    never re-suggest something the user already accepted.

    The caller is responsible for stamping ``via`` / ``at`` and writing the
    result onto ``item.suggested_relationships``. Kept stateless so it can
    be re-used by the cron pass and the interactive capture skill.
    """
    items = brain.get('items') or []
    if not target_slug:
        return []
    index, by_slug = _build_index(items)
    target = by_slug.get(target_slug)
    if not target:
        return []
    # Slugs the target is already connected to (confirmed or pending).
    target_item = next((it for it in items if it.get('slug') == target_slug), None)
    excluded: set[str] = {target_slug}
    if isinstance(target_item, dict):
        for r in target_item.get('relationships') or []:
            if isinstance(r, dict) and isinstance(r.get('to_slug'), str):
                excluded.add(r['to_slug'])
    # brain-level relate edges count too — don't suggest a partner the user
    # has already cross-linked via `notice relate`.
    for rel in brain.get('relationships') or []:
        if not isinstance(rel, dict):
            continue
        f, t = rel.get('from'), rel.get('to')
        if f == target_slug and isinstance(t, str):
            excluded.add(t)
        elif t == target_slug and isinstance(f, str):
            excluded.add(f)
    scored: list[tuple[str, float]] = []
    for slug, v in index:
        if slug in excluded:
            continue
        s = _cosine(target, v)
        if s >= threshold:
            scored.append((slug, s))
    scored.sort(key=lambda x: -x[1])
    return [{'to_slug': s, 'score': round(sc, 3)} for s, sc in scored[:top_k]]


def _apply_suggestions(brain: dict, target_slug: str, *,
                       via: str = 'cron-auto-connect',
                       threshold: float = AUTO_CONNECT_THRESHOLD,
                       top_k: int = AUTO_CONNECT_TOP_K) -> list[dict]:
    """Compute suggestions for ``target_slug`` and append to both sides.

    Idempotent: a (to_slug) already present in suggested_relationships or
    already in confirmed relationships is skipped. Returns the list of
    suggestions actually appended on the target.
    """
    items = brain.get('items') or []
    target = next((it for it in items if it.get('slug') == target_slug), None)
    if not target:
        return []
    sugs = suggest_relationships(brain, target_slug,
                                 threshold=threshold, top_k=top_k)
    if not sugs:
        return []
    ts = now()
    added: list[dict] = []
    target_sugs = target.setdefault('suggested_relationships', [])
    target_confirmed = {r.get('to_slug') for r in (target.get('relationships') or [])
                        if isinstance(r, dict)}
    target_pending = {s.get('to_slug') for s in target_sugs
                       if isinstance(s, dict)}
    for s in sugs:
        other_slug = s['to_slug']
        if other_slug in target_confirmed or other_slug in target_pending:
            continue
        entry = {
            'to_slug': other_slug,
            'score': s['score'],
            'via': via,
            'at': ts,
        }
        target_sugs.append(entry)
        added.append(entry)
        # Mirror on the candidate side too.
        other = next((it for it in items if it.get('slug') == other_slug), None)
        if other is None:
            continue
        other_sugs = other.setdefault('suggested_relationships', [])
        other_confirmed = {r.get('to_slug') for r in (other.get('relationships') or [])
                           if isinstance(r, dict)}
        if target_slug in other_confirmed:
            continue
        if any(isinstance(x, dict) and x.get('to_slug') == target_slug
               for x in other_sugs):
            continue
        other_sugs.append({
            'to_slug': target_slug,
            'score': s['score'],
            'via': via,
            'at': ts,
        })
    return added


def _prune_stale_suggestions(brain: dict, *, days: int = AUTO_CONNECT_PRUNE_DAYS,
                             threshold: float = AUTO_CONNECT_THRESHOLD) -> int:
    """Drop stale or now-too-weak ``suggested_relationships`` entries.

    Returns the count of pruned rows. Used by the nightly auto-connect pass.
    A suggestion missing an ``at`` is treated as fresh (don't blow it away
    on a malformed timestamp), but suggestions below the active threshold are
    removed so threshold increases take effect immediately.
    """
    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
    pruned = 0
    for it in brain.get('items') or []:
        sugs = it.get('suggested_relationships') or []
        if not isinstance(sugs, list):
            continue
        keep: list[dict] = []
        for s in sugs:
            if not isinstance(s, dict):
                continue
            score = s.get('score')
            if isinstance(score, (int, float)) and float(score) < threshold:
                pruned += 1
                continue
            at = s.get('at')
            ts = _parse_iso(at) if isinstance(at, str) else None
            if ts is None or ts >= cutoff:
                keep.append(s)
            else:
                pruned += 1
        if len(keep) != len(sugs):
            it['suggested_relationships'] = keep
            stamp_modified(it)
    return pruned


def _parse_iso(s: str) -> datetime | None:
    if not isinstance(s, str) or not s.strip():
        return None
    txt = s.strip().replace('Z', '+00:00')
    try:
        d = datetime.fromisoformat(txt)
    except ValueError:
        try:
            d = datetime.strptime(s.strip(), '%Y-%m-%d')
        except ValueError:
            return None
    if d.tzinfo is None:
        d = d.replace(tzinfo=timezone.utc)
    return d


def cmd_list(a):
    b = load(); items = b.get('items', [])
    if a.status: items = [x for x in items if x.get('status') == a.status]
    if a.category:
        cat = _resolve_category(a.category)
        items = [x for x in items if x.get('category') == cat]
    if a.tag: items = [x for x in items if a.tag in (x.get('tags') or [])]
    if a.query:
        q = a.query.lower()
        items = [x for x in items if q in (x.get('title', '') + ' ' + x.get('slug', '')).lower()]
    items.sort(key=lambda x: ({'Explore': 0, 'Inbox': 1, 'Done': 2}.get(x.get('status'), 9), x.get('display_id', 0)))
    if a.json:
        print(json.dumps([{
            'id': x.get('display_id'), 'slug': x.get('slug'),
            'title': x.get('title'), 'category': x.get('category'),
            'status': x.get('status'), 'outcome': x.get('outcome'),
        } for x in items], indent=2, ensure_ascii=False))
        return 0
    for x in items:
        print(f"#{x.get('display_id'):>3}  {x.get('status', ''):<7}  {x.get('category', ''):<16}  {x.get('title', '')}")
    print(f"\n{len(items)} item(s).")
    return 0


def cmd_show(a):
    b = load(); it = find(b, a.id)
    if not it:
        print(f'no item with display_id={a.id}', file=sys.stderr); return 1
    if a.json:
        print(json.dumps(it, indent=2, ensure_ascii=False)); return 0
    print(f"#{it['display_id']}  {it['title']}")
    print(f"  slug:     {it.get('slug')}")
    print(f"  status:   {it.get('status')}" + (f"  outcome={it.get('outcome')}" if it.get('outcome') else ''))
    print(f"  category: {it.get('category')}")
    if it.get('tags'): print('  tags:     ' + ', '.join(it['tags']))
    if it.get('source'): print(f"  source:   {it.get('source')}")
    if it.get('project_file'): print(f"  project:  {it.get('project_file')}")
    phases = it.get('phases') or {}
    notes = (phases.get('inbox') or {}).get('notes') or []
    if notes:
        print('  notes:'); [print(f'    - {n}') for n in notes]
    ex = phases.get('explore') or {}
    if ex.get('intent'): print(f"  intent:   {ex['intent']}")
    if ex.get('next_action'): print(f"  next:     {ex['next_action']}")
    if ex.get('open_questions'):
        print('  questions:'); [print(f'    ? {q}') for q in ex['open_questions']]
    arts = it.get('artifacts') or []
    if arts:
        print(f'  artifacts ({len(arts)}):')
        [print(f"    - [{x.get('kind', '?')}] {x.get('title') or x.get('url_or_path')}") for x in arts]
    # Lineage
    rels = [r for r in (b.get('relationships') or [])
            if r.get('from') == it.get('slug') or r.get('to') == it.get('slug')]
    if rels:
        print('  lineage:')
        for r in rels:
            arrow = '→' if r.get('from') == it.get('slug') else '←'
            other = r.get('to') if arrow == '→' else r.get('from')
            print(f"    {arrow} {r.get('type')} :: {other}")
    return 0


def cmd_capture(a):
    cat = _resolve_category(a.category)
    if cat == "Read":
        if _looks_like_url(a.title) or _looks_like_url(a.note):
            raise SystemExit(
                "refusing to capture a raw URL as a 'Read' item under Option A.\n"
                "Use:  notice link capture <url> [--title T] [--note N] [--source S] [--tag T]\n"
                "which records the URL in link_triage as 'unexplored' and lets the "
                "explainer pipeline promote it onto the Read page."
            )
        print(
            "warning: 'Read' is legacy under Option A; the Read page is now driven by "
            "Link Triage explainer-ready records. Continuing for backwards compatibility.",
            file=sys.stderr,
        )

    # v3.0: type defaults from category when not given. The CLI accepts
    # --type for explicit overrides (e.g. "Idea captured under Research").
    requested_type = getattr(a, 'type', None)
    if requested_type and requested_type not in TYPES:
        raise SystemExit(f"unknown type: {requested_type}. allowed: {', '.join(TYPES)}")
    itype = requested_type or CATEGORY_TO_TYPE.get(cat, "note")

    def _mutate(b):
        items = b.setdefault('items', [])
        nid = max([x.get('display_id', 0) for x in items] or [0]) + 1
        s = slug(a.title); base = s; n = 2
        taken = {x.get('slug') for x in items}
        while s in taken:
            s = f'{base}-{n}'; n += 1
        note = a.note
        ts = now()
        item = {
            'display_id': nid, 'slug': s, 'title': a.title,
            'type': itype,
            'category': cat, 'status': 'Inbox', 'outcome': None, 'phase': 'inbox',
            'source': a.source, 'created_at': today(), 'updated_at': ts,
            'last_modified_at': ts,
            'sources': [{'via': a.source or 'notice CLI', 'raw': note or a.title, 'at': ts}],
            'tags': a.tag or [], 'project_file': None,
            'phases': {
                'inbox':   {'raw_capture': note, 'notes': [note] if note else []},
                'explore': {'intent': None, 'open_questions': [], 'next_action': None, 'started_at': None},
                'done':    {'outcome': None, 'summary': None, 'completed_at': None},
            },
            'artifacts': [],
            'relationships': [],
            'notes_path': None,
            'folder_path': f"items/{s}/",
        }
        items.append(item)
        try:
            _apply_suggestions(b, s, via='auto-on-capture')
        except Exception as exc:  # noqa: BLE001 — capture must never fail
            print(f"auto-connect: capture suggestion failed: {exc}", file=sys.stderr)
        return f"#{nid} ({cat}/{itype}) {a.title}", (nid, s)

    nid, new_slug = transaction('capture', _mutate)
    ensure_item_folder(new_slug)
    print(f'captured #{nid}  {cat}/{itype}  {a.title}')
    return 0


def cmd_explore(a):
    # Pre-check (advisory) — authoritative check happens inside the lock.
    pre = find(load(), a.id)
    if pre and pre.get('category') == 'Ideas' and not (a.next or '').strip():
        print(
            "hint: moving an Idea to Explore should answer the smallest "
            "validation/build step.\n      Pass --next to record it. Continuing without one.",
            file=sys.stderr,
        )

    def _mutate(b):
        it = find(b, a.id)
        if not it:
            raise SystemExit(f'no item with display_id={a.id}')
        it['status'] = 'Explore'; it['outcome'] = None; it['phase'] = 'explore'
        stamp_modified(it)
        ensure_v3_fields(it)
        ex = it.setdefault('phases', {}).setdefault('explore', {
            'intent': None, 'open_questions': [], 'next_action': None, 'started_at': None,
        })
        if a.intent: ex['intent'] = a.intent
        if a.next: ex['next_action'] = a.next
        if a.deadline: ex['deadline'] = a.deadline
        ex['started_at'] = ex.get('started_at') or now()
        return f"#{a.id} moved to Explore" + (f" — {a.intent}" if a.intent else '')

    transaction('explore', _mutate)
    print(f'#{a.id} -> Explore' + (f'  next: {a.next}' if a.next else ''))
    return 0


def cmd_done(a):
    def _mutate(b):
        it = find(b, a.id)
        if not it:
            raise SystemExit(f'no item with display_id={a.id}')
        it['status'] = 'Done'; it['outcome'] = a.outcome; it['phase'] = 'done'
        if 'reading_queue' in it:
            it['reading_queue'] = False
        stamp_modified(it)
        ensure_v3_fields(it)
        d = it.setdefault('phases', {}).setdefault('done', {})
        d.update({'outcome': a.outcome, 'summary': a.summary, 'completed_at': now()})
        # Keep the embedded link sub-object terminal-state aligned with item.
        link = it.get('link')
        if isinstance(link, dict):
            link['status'] = _terminal_link_status_for(a.outcome)
            # Mirror into deprecated link_triage[] for one cycle.
            _sync_link_triage_mirror(b, it)
        return f"#{a.id} marked Done/{a.outcome}" + (f" — {a.summary}" if a.summary else '')

    transaction('done', _mutate)
    print(f'#{a.id} -> Done/{a.outcome}')
    return 0


def _terminal_link_status_for(outcome: str | None) -> str:
    """Map item outcome onto the matching terminal link.status."""
    if outcome in ("completed", "read"):
        return "read"
    if outcome == "dropped":
        return "dropped"
    return "archived"  # archived, stale, graduated, anything else


def _sync_link_triage_mirror(b: dict, item: dict) -> None:
    """Push item.link state into the deprecated link_triage[] mirror.

    Plan 03 keeps the mirror so existing renderers keep working for one
    cycle. Every mutation that touches item.link also writes-through to
    the matching mirror row (matched by item_slug + canonical_url).
    Plan 4+ retires the mirror; this helper goes away with it.
    """
    link = item.get('link')
    if not isinstance(link, dict):
        return
    triage = b.setdefault('link_triage', [])
    slug = item.get('slug')
    canonical = link.get('canonical_url') or canonicalize_url(link.get('url', ''))
    rec = next(
        (lt for lt in triage
         if lt.get('item_slug') == slug
         and (lt.get('canonical_url') == canonical
              or canonicalize_url(lt.get('url', '')) == canonical)),
        None,
    )
    if rec is None:
        rec = {
            'item_slug': slug,
            'url': link.get('url', ''),
            'canonical_url': canonical,
            'status': link.get('status', 'unexplored'),
            'captured_at': item.get('created_at') or today(),
            'updated_at': now(),
            'explainer': link.get('explainer_md'),
            'notes': None,
        }
        triage.append(rec)
        return
    rec['status'] = link.get('status', rec.get('status', 'unexplored'))
    rec['canonical_url'] = canonical
    rec['updated_at'] = now()
    if link.get('explainer_md'):
        rec['explainer'] = link['explainer_md']


def cmd_next(a):
    """Set or update next_action on an item (helper for the Ideas→Explore gate)."""
    def _mutate(b):
        it = find(b, a.id)
        if not it:
            raise SystemExit(f'no item with display_id={a.id}')
        ex = it.setdefault('phases', {}).setdefault('explore', {
            'intent': None, 'open_questions': [], 'next_action': None, 'started_at': None,
        })
        ex['next_action'] = a.text
        stamp_modified(it)
        ensure_v3_fields(it)
        return f"#{a.id} next_action set: {a.text}"

    transaction('next', _mutate)
    print(f'#{a.id} next: {a.text}')
    return 0


def cmd_link(a):
    """Manage link_triage records (Option A lifecycle)."""
    if a.action == 'list':
        # Read-only — no lock needed.
        b = load()
        rows = b.get('link_triage') or []
        if a.status:
            rows = [r for r in rows if r.get('status') == a.status]
        if a.json:
            print(json.dumps(rows, indent=2, ensure_ascii=False)); return 0
        for r in rows:
            print(f"[{r.get('status'):<16}] {r.get('url')}   (item={r.get('item_slug')})")
        print(f"\n{len(rows)} link(s).")
        return 0

    if a.action == 'add':
        resolved_status = _resolve_link_status(a.status)

        def _mutate(b):
            owner = find(b, a.id)
            if not owner:
                raise SystemExit(f'no item with display_id={a.id}')
            url = a.url
            canonical = canonicalize_url(url)
            # v3.0: attach the link sub-object onto the owning item.
            owner_slug = owner.get('slug') or ''
            owner_link = owner.get('link')
            if not isinstance(owner_link, dict):
                owner_link = {
                    'url': url,
                    'canonical_url': canonical,
                    'status': resolved_status,
                    'explainer_path': f"items/{owner_slug}/explainer.html" if owner_slug else None,
                    'explainer_md': None,
                    'explainer_written_at': None,
                    'domain': _domain_of(canonical),
                }
                owner['link'] = owner_link
            else:
                owner_link['status'] = resolved_status
                owner_link['canonical_url'] = canonical
                owner_link.setdefault('url', url)
                owner_link.setdefault('domain', _domain_of(canonical))
                if owner_slug:
                    owner_link['explainer_path'] = f"items/{owner_slug}/explainer.html"
            append_source(owner, 'link.add', url)
            stamp_modified(owner)
            ensure_v3_fields(owner)
            _sync_link_triage_mirror(b, owner)
            # Also annotate the mirror row with the user-supplied note.
            if a.note:
                triage = b.setdefault('link_triage', [])
                rec = next(
                    (lt for lt in triage
                     if lt.get('item_slug') == owner.get('slug')
                     and (lt.get('canonical_url') == canonical
                          or canonicalize_url(lt.get('url', '')) == canonical)),
                    None,
                )
                if rec is not None:
                    rec['notes'] = a.note
            return f"#{a.id} <- {a.url} [{resolved_status}]", resolved_status

        status_out = transaction('link.add', _mutate)
        print(f"link added: {a.url} ({status_out})")
        return 0

    if a.action == 'capture':
        url = a.url
        if not _looks_like_url(url):
            raise SystemExit(f"not a URL: {url!r}")
        canonical = canonicalize_url(url)

        def _mutate(b):
            items = b.setdefault('items', [])
            # v3.0 dedup: check items[] for an existing canonical match
            # first. Fall back to scanning the deprecated link_triage[]
            # mirror so transition-period rows still dedup correctly.
            existing_item = next(
                (it for it in items
                 if isinstance(it.get('link'), dict)
                 and (it['link'].get('canonical_url') == canonical
                      or canonicalize_url(it['link'].get('url', '')) == canonical)),
                None,
            )
            if existing_item is None:
                triage = b.setdefault('link_triage', [])
                existing_lt = next(
                    (lt for lt in triage
                     if canonicalize_url(lt.get('url', '')) == canonical
                     or lt.get('canonical_url') == canonical),
                    None,
                )
                if existing_lt is not None:
                    existing_item = find_by_slug(b, existing_lt.get('item_slug') or '')
            if existing_item is not None:
                src = a.source or 'link capture'
                append_source(existing_item, src, url)
                # If the item lacks a link sub-object (e.g. it was a v2.1
                # item that pre-dated the embed), build one now.
                existing_slug = existing_item.get('slug') or ''
                link = existing_item.get('link')
                if not isinstance(link, dict):
                    link = {
                        'url': url,
                        'canonical_url': canonical,
                        'status': 'unexplored',
                        'explainer_path': f"items/{existing_slug}/explainer.html" if existing_slug else None,
                        'explainer_md': None,
                        'explainer_written_at': None,
                        'domain': _domain_of(canonical),
                    }
                    existing_item['link'] = link
                else:
                    link['canonical_url'] = canonical
                    link.setdefault('url', url)
                    link.setdefault('domain', _domain_of(canonical))
                    if existing_slug:
                        link['explainer_path'] = f"items/{existing_slug}/explainer.html"
                stamp_modified(existing_item)
                ensure_v3_fields(existing_item)
                _sync_link_triage_mirror(b, existing_item)
                detail = (
                    f"dedup: {url} merged into item "
                    f"(canonical={canonical}, slug={existing_item.get('slug')})"
                )
                return detail, {'action': 'deduped',
                                'slug': existing_item.get('slug'),
                                'display_id': existing_item.get('display_id'),
                                'title': existing_item.get('title') or url,
                                'canonical': canonical}
            # Brand-new capture — mint a v3.0 link item.
            title = a.title or url
            s = slug(title); base = s; n = 2
            taken = {x.get('slug') for x in items}
            while s in taken:
                s = f'{base}-{n}'; n += 1
            nid = max([x.get('display_id', 0) for x in items] or [0]) + 1
            capture_note = a.note or url
            ts = now()
            item = {
                'display_id': nid, 'slug': s, 'title': title,
                'type': 'link',
                'category': 'Uncategorized', 'status': 'Inbox', 'outcome': None,
                'phase': 'inbox',
                'reading_queue': True,
                'source': a.source or 'link capture',
                'created_at': today(), 'updated_at': ts,
                'last_modified_at': ts,
                'sources': [{'via': a.source or 'link capture', 'raw': url, 'at': ts}],
                'tags': a.tag or [], 'project_file': None,
                'phases': {
                    'inbox':   {'raw_capture': url, 'notes': [capture_note]},
                    'explore': {'intent': None, 'open_questions': [], 'next_action': None, 'started_at': None},
                    'done':    {'outcome': None, 'summary': None, 'completed_at': None},
                },
                'artifacts': [],
                'link': {
                    'url': url,
                    'canonical_url': canonical,
                    # Plan 04: explainer is written into the item's own
                    # folder. The path is set even before the cron has
                    # produced the file (plan 6 lands the cron rewrite).
                    'status': 'unexplored',
                    'explainer_path': f"items/{s}/explainer.html",
                    'explainer_md': None,
                    'explainer_written_at': None,
                    'domain': _domain_of(canonical),
                },
                'relationships': [],
                'notes_path': None,
                'folder_path': f"items/{s}/",
            }
            items.append(item)
            # Mirror into the deprecated link_triage[] for one cycle so
            # the existing Link Triage / Read renderers keep working
            # until plans 4/5 retire them.
            _sync_link_triage_mirror(b, item)
            try:
                _apply_suggestions(b, s, via='auto-on-capture')
            except Exception as exc:  # noqa: BLE001 — capture must never fail
                print(f"auto-connect: link.capture suggestion failed: {exc}",
                      file=sys.stderr)
            return f"#{nid} <- {url} [unexplored]", {'action': 'captured',
                                                    'slug': s, 'display_id': nid}

        result = transaction('link.capture', _mutate)
        if isinstance(result, dict) and result.get('action') == 'deduped':
            print(f"Already captured (no-op): {result.get('title') or url}")
        else:
            nid = (result or {}).get('display_id') if isinstance(result, dict) else result
            slug_out = (result or {}).get('slug') if isinstance(result, dict) else None
            if slug_out:
                ensure_item_folder(slug_out)
            print(f"captured link #{nid} {url} (unexplored)")
        return 0

    if a.action == 'status':
        new_status = _resolve_link_status(a.status)
        canonical_target = canonicalize_url(a.url)

        def _mutate(b):
            # v3.0: source of truth is items[].link.status. Find by
            # canonical URL on the embedded link sub-object first; fall
            # back to the deprecated link_triage[] mirror so legacy rows
            # without a matched item still resolve.
            owner = next(
                (it for it in b.get('items') or []
                 if isinstance(it.get('link'), dict)
                 and (it['link'].get('canonical_url') == canonical_target
                      or canonicalize_url(it['link'].get('url', '')) == canonical_target
                      or it['link'].get('url') == a.url)),
                None,
            )
            if owner is None:
                triage = b.get('link_triage') or []
                found = next((lt for lt in triage if lt.get('url') == a.url
                              or lt.get('canonical_url') == canonical_target), None)
                if not found:
                    raise SystemExit(f"no link record for {a.url}")
                owner = find_by_slug(b, found.get('item_slug') or '')
                if owner is None:
                    raise SystemExit(
                        f"link_triage row for {a.url} points at missing item "
                        f"'{found.get('item_slug')}'"
                    )
                # Ensure the owner now carries the embedded link.
                if not isinstance(owner.get('link'), dict):
                    owner_slug2 = owner.get('slug') or ''
                    owner['link'] = {
                        'url': found.get('url') or a.url,
                        'canonical_url': found.get('canonical_url') or canonical_target,
                        'status': new_status,
                        'explainer_path': f"items/{owner_slug2}/explainer.html" if owner_slug2 else None,
                        'explainer_md': found.get('explainer'),
                        'explainer_written_at': None,
                        'domain': _domain_of(canonical_target),
                    }
            link = owner['link']
            link['status'] = new_status
            if a.explainer:
                # The legacy `explainer` field carried markdown content,
                # not a file path. Preserve that semantic on the v3 side.
                link['explainer_md'] = a.explainer
            if new_status == 'read':
                # Status read on a link is the canonical "mark-read" lane:
                # also promote the item to Done/read so the lifecycles
                # stay in lockstep (the v2.1 desync this plan fixes).
                owner['status'] = 'Done'
                owner['outcome'] = 'read'
                owner['phase'] = 'done'
                if 'reading_queue' in owner:
                    owner['reading_queue'] = False
                d = owner.setdefault('phases', {}).setdefault('done', {})
                d['outcome'] = 'read'
                d.setdefault('completed_at', now())
            elif new_status == 'dropped':
                owner['status'] = 'Done'
                owner['outcome'] = 'dropped'
                owner['phase'] = 'done'
                if 'reading_queue' in owner:
                    owner['reading_queue'] = False
                d = owner.setdefault('phases', {}).setdefault('done', {})
                d['outcome'] = 'dropped'
                d.setdefault('completed_at', now())
            elif new_status == 'archived':
                owner['status'] = 'Done'
                owner['outcome'] = 'archived'
                owner['phase'] = 'done'
                if 'reading_queue' in owner:
                    owner['reading_queue'] = False
                d = owner.setdefault('phases', {}).setdefault('done', {})
                d['outcome'] = 'archived'
                d.setdefault('completed_at', now())
            stamp_modified(owner)
            ensure_v3_fields(owner)
            if a.note:
                append_source(owner, 'link.status.note', a.note)
            _sync_link_triage_mirror(b, owner)
            return f"{a.url} -> {new_status}"

        transaction('link.status', _mutate)
        print(f"{a.url} -> {new_status}")
        return 0

    return 1


def _domain_of(url: str) -> str:
    try:
        return (urlparse(url).netloc or "").lower()
    except Exception:
        return ""


def cmd_set_explainer(a):
    """Record that the explainer for ``slug`` is ready (Plan 06).

    Used by the autonomous link-triage cron pass. Atomic update path:

      - ``items[].link.explainer_path`` is set to the supplied repo-relative
        path (typically ``items/<slug>/explainer.html``).
      - ``items[].link.explainer_written_at`` is stamped.
      - ``items[].link.status`` is promoted from ``unexplored`` to
        ``explainer_ready`` (no-op if already further along).
      - ``items[].phase`` is auto-promoted ``inbox`` → ``explore`` so the
        item surfaces on the read lane.
      - The deprecated ``link_triage[]`` mirror is synced via the existing
        helper so v2.x renderers stay correct for one transition cycle.

    Failure path: if the slug isn't a link item, or the path doesn't exist
    on disk, fail loudly. Cron pipelines depend on this contract.
    """
    slug_arg = a.slug
    path = a.path
    written_at = a.written_at or now()
    # Don't insist the file exists when the caller passes --no-verify-path
    # (useful for tests/dry runs). Otherwise validate on disk.
    if not getattr(a, 'no_verify_path', False):
        target = REPO / path
        if not target.exists():
            raise SystemExit(
                f"set-explainer: file does not exist at {target}. "
                "Write the explainer HTML before calling set-explainer."
            )
        if ensure_explainer_backlink(target):
            print(
                f"set-explainer: inserted missing item-page backlink into {path}",
                file=sys.stderr,
            )

    def _mutate(b):
        it = find_by_slug(b, slug_arg) or find(b, _try_int(slug_arg))
        if not it:
            raise SystemExit(f"no item with slug or id '{slug_arg}'")
        if it.get('type') != 'link':
            raise SystemExit(
                f"set-explainer: item '{slug_arg}' is type "
                f"'{it.get('type')}', not 'link'."
            )
        link = it.get('link')
        if not isinstance(link, dict):
            raise SystemExit(
                f"set-explainer: item '{slug_arg}' has no embedded link "
                "sub-object. Run `notice link add` or recapture first."
            )
        link['explainer_path'] = path
        link['explainer_written_at'] = written_at
        old_link_status = link.get('status')
        if old_link_status == 'unexplored':
            link['status'] = 'explainer_ready'
        old_phase = it.get('phase')
        if old_phase == 'inbox':
            it['phase'] = 'explore'
            # Keep the legacy `status` field aligned so existing renderers
            # that key off `status` (not just `phase`) lift the item out of
            # Inbox too.
            if it.get('status') == 'Inbox':
                it['status'] = 'Explore'
            ex = it.setdefault('phases', {}).setdefault('explore', {
                'intent': None, 'open_questions': [],
                'next_action': None, 'started_at': None,
            })
            if not ex.get('started_at'):
                ex['started_at'] = written_at
        stamp_modified(it)
        ensure_v3_fields(it)
        _sync_link_triage_mirror(b, it)
        try:
            _apply_suggestions(b, it.get('slug') or '', via='auto-on-explainer')
        except Exception as exc:  # noqa: BLE001 — set-explainer must never fail
            print(f"auto-connect: set-explainer suggestion failed: {exc}",
                  file=sys.stderr)
        return (
            f"{slug_arg} set-explainer {path} "
            f"(link.status {old_link_status}->{link['status']}, "
            f"phase {old_phase}->{it.get('phase')})"
        )

    transaction('set-explainer', _mutate)
    print(f"{slug_arg}: explainer ready ({path})")
    return 0


def cmd_retype(a):
    """Change an item's `type` without changing its category."""
    if a.type not in TYPES:
        raise SystemExit(f"unknown type: {a.type}. allowed: {', '.join(TYPES)}")

    def _mutate(b):
        it = find_by_slug(b, a.slug) or find(b, _try_int(a.slug))
        if not it:
            raise SystemExit(f"no item with slug or id '{a.slug}'")
        old = it.get('type')
        it['type'] = a.type
        stamp_modified(it)
        ensure_v3_fields(it)
        return f"{it.get('slug')} retype {old} -> {a.type}"

    transaction('retype', _mutate)
    print(f"{a.slug} retyped to {a.type}")
    return 0


def cmd_recategorize(a):
    """Change an item's `category` without changing its type."""
    cat = _resolve_category(a.category)

    def _mutate(b):
        it = find_by_slug(b, a.slug) or find(b, _try_int(a.slug))
        if not it:
            raise SystemExit(f"no item with slug or id '{a.slug}'")
        old = it.get('category')
        it['category'] = cat
        stamp_modified(it)
        ensure_v3_fields(it)
        return f"{it.get('slug')} recategorize {old} -> {cat}"

    transaction('recategorize', _mutate)
    print(f"{a.slug} recategorized to {cat}")
    return 0


# ---------------------------------------------------------------------------
# Plan 08 — graduation rail.
#
# `notice graduate <slug>` records intent on the Pi: it marks the item as
# Done/graduated and queues a pending entry in ``brain.json.pending_graduations``.
# A companion script (`scripts/mc_graduate_sync.py`) runs on the user's
# MacBook to materialise ``~/AI/projects/<slug>/`` and calls
# `notice confirm-graduation <slug>` back over SSH when the folder lands.
# `notice ungraduate <slug>` undoes the NB-side state but never deletes the
# spawned folder.
# ---------------------------------------------------------------------------

ITEM_NB_URL_BASE = "https://hermes-agent.tail8e09c4.ts.net/items"
GRADUATION_STATUSES = ("pending", "confirmed", "failed")


def _default_project_folder(slug: str) -> str:
    return f"~/AI/projects/{slug}/"


def cmd_graduate(a):
    """Mark an idea/project as graduated and queue a pending folder spawn.

    Idempotent: a re-run on an already-graduated item is a no-op and prints
    a friendly message. The actual ``~/AI/projects/<slug>/`` folder is
    created MacBook-side by ``mc_graduate_sync.py``; the Pi only records
    intent here.
    """
    slug_arg = a.slug
    already = {'flag': False}

    def _mutate(b):
        it = find_by_slug(b, slug_arg) or find(b, _try_int(slug_arg))
        if not it:
            raise SystemExit(f"no item with slug or id '{slug_arg}'")
        itype = it.get('type')
        if itype not in ('idea', 'project'):
            raise SystemExit(
                f"graduate is for idea/project items; "
                f"'{it.get('slug')}' is type '{itype}'"
            )
        if it.get('outcome') == 'graduated':
            already['flag'] = True
            return None
        slug_actual = it.get('slug') or slug_arg
        ts = now()
        it['status'] = 'Done'
        it['outcome'] = 'graduated'
        it['phase'] = 'done'
        tf = it.setdefault('template_fields', {})
        tf['project_folder'] = tf.get('project_folder') or _default_project_folder(slug_actual)
        tf.setdefault('graduated_at', ts)
        d = it.setdefault('phases', {}).setdefault('done', {})
        d['outcome'] = 'graduated'
        d.setdefault('completed_at', ts)
        stamp_modified(it)
        ensure_v3_fields(it)
        # Queue the pending spawn. Idempotent — if a row already exists for
        # this slug (e.g. an unconfirmed prior attempt) keep it in place.
        queue = b.setdefault('pending_graduations', [])
        existing = next((g for g in queue if isinstance(g, dict)
                          and g.get('slug') == slug_actual), None)
        if existing is None:
            queue.append({
                'slug': slug_actual,
                'title': it.get('title', slug_actual),
                'queued_at': ts,
                'status': 'pending',
                'nb_item_url': f"{ITEM_NB_URL_BASE}/{slug_actual}/",
                'template_fields': {
                    'project_folder': tf.get('project_folder'),
                    'premise': tf.get('premise'),
                    'kill_rule': tf.get('kill_rule'),
                    'posture': tf.get('posture'),
                },
            })
        else:
            existing.setdefault('queued_at', ts)
            existing['status'] = existing.get('status') or 'pending'
        return f"{slug_actual} graduated -> {tf['project_folder']}"

    transaction('graduate', _mutate)
    if already['flag']:
        print(f"[notice] {slug_arg}: already graduated")
    else:
        print(
            f"[notice] {slug_arg}: graduation queued. "
            "Run `mc-graduate-sync` on your MacBook to spawn the folder."
        )
    return 0


def cmd_ungraduate(a):
    """Reverse the NB-side graduation link without touching the local folder."""
    slug_arg = a.slug

    def _mutate(b):
        it = find_by_slug(b, slug_arg) or find(b, _try_int(slug_arg))
        if not it:
            raise SystemExit(f"no item with slug or id '{slug_arg}'")
        slug_actual = it.get('slug') or slug_arg
        changed = False
        if it.get('outcome') == 'graduated':
            it['outcome'] = None
            it['status'] = 'Explore'
            it['phase'] = 'explore'
            d = it.setdefault('phases', {}).setdefault('done', {})
            d['outcome'] = None
            d['completed_at'] = None
            changed = True
        tf = it.get('template_fields') or {}
        if 'project_folder' in tf:
            tf.pop('project_folder', None)
            changed = True
        if 'graduated_at' in tf:
            tf.pop('graduated_at', None)
            changed = True
        if changed:
            stamp_modified(it)
            ensure_v3_fields(it)
        before = len(b.get('pending_graduations') or [])
        b['pending_graduations'] = [
            g for g in (b.get('pending_graduations') or [])
            if not (isinstance(g, dict) and g.get('slug') == slug_actual)
        ]
        removed = before - len(b['pending_graduations'])
        if not changed and not removed:
            return None
        return (
            f"{slug_actual} ungraduate "
            f"(removed {removed} pending row{'s' if removed != 1 else ''})"
        )

    transaction('ungraduate', _mutate)
    print(
        f"[notice] {slug_arg}: ungraduated. "
        f"The local folder ~/AI/projects/{slug_arg}/ was NOT removed."
    )
    return 0


def cmd_confirm_graduation(a):
    """Called by ``mc_graduate_sync.py`` after the local folder is created."""
    slug_arg = a.slug
    created_path = getattr(a, 'created_path', None)
    already = {'flag': False}

    def _mutate(b):
        it = find_by_slug(b, slug_arg) or find(b, _try_int(slug_arg))
        if not it:
            raise SystemExit(f"no item with slug or id '{slug_arg}'")
        slug_actual = it.get('slug') or slug_arg
        queue = b.get('pending_graduations') or []
        row = next((g for g in queue if isinstance(g, dict)
                    and g.get('slug') == slug_actual), None)
        if row is None:
            raise SystemExit(f"no pending graduation for {slug_actual}")
        if row.get('status') == 'confirmed':
            already['flag'] = True
            return None
        ts = now()
        row['status'] = 'confirmed'
        row['confirmed_at'] = ts
        if created_path:
            row['created_path'] = created_path
            tf = it.setdefault('template_fields', {})
            tf['project_folder'] = created_path
            stamp_modified(it)
            ensure_v3_fields(it)
        return f"{slug_actual} graduation confirmed" + (
            f" at {created_path}" if created_path else ""
        )

    transaction('confirm-graduation', _mutate)
    print(f"[notice] {slug_arg}: graduation confirmed")
    return 0


# Per-type ``template_fields`` whitelist. Must stay in lockstep with
# ``mc_pages.TEMPLATE_FIELDS_BY_TYPE`` and the per-type table in
# ``docs/notice-board-v3.md``.
TEMPLATE_FIELDS_BY_TYPE: dict[str, tuple[str, ...]] = {
    "link":     ("reason",),
    "idea":     ("premise", "kill_rule", "posture", "project_folder"),
    "learning": ("goal", "working_definition", "success_criteria", "sources"),
    "project":  ("north_star", "stack", "decisions", "repo_url", "deploy_url",
                 "project_folder"),
    "admin":    ("action", "deadline", "status"),
    "note":     (),
}
# Keys whose value should be parsed as JSON (list / object); everything
# else stays a string.
_TEMPLATE_FIELD_JSON_KEYS = {
    "success_criteria", "sources", "stack", "decisions",
}
_ADMIN_STATUSES = ("pending", "doing", "done", "dropped")


def cmd_set(a):
    """Set an item's ``template_fields.<key>`` value, with per-type whitelist."""
    raw = a.value
    key = a.key

    def _mutate(b):
        it = find_by_slug(b, a.slug) or find(b, _try_int(a.slug))
        if not it:
            raise SystemExit(f"no item with slug or id '{a.slug}'")
        itype = it.get('type') or 'note'
        allowed = TEMPLATE_FIELDS_BY_TYPE.get(itype, ())
        if not allowed:
            raise SystemExit(
                f"type '{itype}' has no settable template fields"
            )
        if key not in allowed:
            raise SystemExit(
                f"Unknown field '{key}' for type '{itype}'. "
                f"Valid: {', '.join(allowed)}."
            )
        # Parse value. JSON for the structured keys; raw string otherwise.
        if key in _TEMPLATE_FIELD_JSON_KEYS:
            try:
                parsed = json.loads(raw)
            except json.JSONDecodeError as e:
                raise SystemExit(
                    f"value for '{key}' must be JSON ({e}); "
                    f"e.g. '[\"a\",\"b\"]' or '{{\"frontend\":\"Next.js\"}}'"
                )
        else:
            parsed = raw
        # Lightweight per-key sanity checks.
        if itype == 'admin' and key == 'status' and parsed not in _ADMIN_STATUSES:
            raise SystemExit(
                f"admin.status must be one of {', '.join(_ADMIN_STATUSES)}"
            )
        if key == 'stack' and not isinstance(parsed, dict):
            raise SystemExit("stack must be a JSON object")
        if key in ('success_criteria', 'sources', 'decisions') \
                and not isinstance(parsed, list):
            raise SystemExit(f"{key} must be a JSON list")
        tf = it.setdefault('template_fields', {})
        old = tf.get(key)
        tf[key] = parsed
        stamp_modified(it)
        ensure_v3_fields(it)
        summary = f"{it.get('slug')} set {key} = {raw}"
        if old is not None:
            summary += f" (was {json.dumps(old, ensure_ascii=False)})"
        return summary

    transaction('set', _mutate)
    print(f"{a.slug}: set {key}")
    return 0


def _try_int(s: str):
    try:
        return int(s)
    except (TypeError, ValueError):
        return -1


def cmd_relate(a):
    """Add a relationship edge between two items."""
    if a.type not in REL_TYPES:
        print(f"unknown relationship type: {a.type}", file=sys.stderr); return 1

    def _mutate(b):
        src = find(b, a.id_from)
        dst = find(b, a.id_to)
        if not src or not dst:
            raise SystemExit("one or both items not found")
        rels = b.setdefault('relationships', [])
        rels.append({
            'from': src.get('slug'),
            'to':   dst.get('slug'),
            'type': a.type,
            'created_at': now(),
        })
        return f"#{a.id_from} {a.type} #{a.id_to}"

    transaction('relate', _mutate)
    print(f"edge: #{a.id_from} -[{a.type}]-> #{a.id_to}")
    return 0


def _resolve_slug_arg(brain: dict, raw: str) -> dict:
    """Look up an item by slug or numeric display_id; raise SystemExit otherwise."""
    it = find_by_slug(brain, raw) or find(brain, _try_int(raw))
    if not it:
        raise SystemExit(f"no item with slug or id '{raw}'")
    return it


def cmd_compute_suggestions(a):
    """Compute (and persist) suggested_relationships for one item.

    Used by the cron pass and by humans during manual debugging. Threshold
    and top-K are tunable via flags for testing.
    """
    slug_arg = a.slug
    via = a.via or 'cron-auto-connect'
    threshold = float(a.threshold) if a.threshold is not None else AUTO_CONNECT_THRESHOLD
    top_k = int(a.top_k) if a.top_k is not None else AUTO_CONNECT_TOP_K

    def _mutate(b):
        target = _resolve_slug_arg(b, slug_arg)
        added = _apply_suggestions(b, target['slug'],
                                   via=via, threshold=threshold, top_k=top_k)
        if added:
            stamp_modified(target)
            ensure_v3_fields(target)
        if not added:
            return None
        snippets = ', '.join(f"{s['to_slug']}({s['score']})" for s in added)
        return f"{target['slug']} compute-suggestions {len(added)} new — {snippets}"

    transaction('compute-suggestions', _mutate)
    print(f"{slug_arg}: compute-suggestions done")
    return 0


def cmd_auto_connect(a):
    """Run the auto-connect pass over recent (or all) items.

    Defaults to items modified in the last 24h. Pass ``--recent-hours N`` to
    widen the window or ``--all`` to walk every item. Stale suggestions
    older than ``AUTO_CONNECT_PRUNE_DAYS`` are always pruned at the end.
    """
    threshold = float(a.threshold) if a.threshold is not None else AUTO_CONNECT_THRESHOLD
    top_k = int(a.top_k) if a.top_k is not None else AUTO_CONNECT_TOP_K
    via = a.via or 'cron-auto-connect'
    walk_all = bool(getattr(a, 'all', False))
    hours = float(a.recent_hours) if a.recent_hours is not None else 24.0

    def _mutate(b):
        items = b.get('items') or []
        now_dt = datetime.now(timezone.utc)
        cutoff = now_dt - timedelta(hours=hours)
        targets: list[str] = []
        for it in items:
            slug_v = it.get('slug')
            if not isinstance(slug_v, str) or not slug_v:
                continue
            if walk_all:
                targets.append(slug_v)
                continue
            ts = _parse_iso(it.get('last_modified_at')
                            or it.get('updated_at')
                            or it.get('created_at'))
            if ts is None:
                continue
            if ts < cutoff:
                continue
            targets.append(slug_v)
        added_total = 0
        for slug_v in targets:
            try:
                added = _apply_suggestions(b, slug_v,
                                           via=via, threshold=threshold,
                                           top_k=top_k)
            except Exception as exc:  # noqa: BLE001 — cron must not crash
                print(f"auto-connect: failed for {slug_v}: {exc}", file=sys.stderr)
                continue
            if added:
                added_total += len(added)
                target_item = next((it for it in items if it.get('slug') == slug_v), None)
                if target_item is not None:
                    stamp_modified(target_item)
                    ensure_v3_fields(target_item)
        pruned = _prune_stale_suggestions(b, threshold=threshold)
        if not targets and not pruned:
            return None
        return (
            f"auto-connect pass over {len(targets)} item(s): "
            f"added {added_total} suggestion(s), pruned {pruned} stale"
        )

    transaction('auto-connect', _mutate)
    if walk_all:
        print("auto-connect: walked all items")
    else:
        print(f"auto-connect: walked items modified in the last {hours:g}h")
    return 0


def cmd_connect(a):
    """Promote a (pending) suggestion to a confirmed relationship.

    Mirrors on the other side so both items reference each other. Accepts a
    suggestion that never existed too — useful when the user types a
    connect command from outside the suggested list.
    """
    slug_arg = a.slug
    other_arg = a.other
    via = a.via or 'manual'
    if via not in ('auto', 'manual'):
        raise SystemExit(f"unknown --via '{via}'. allowed: auto, manual")

    def _mutate(b):
        src = _resolve_slug_arg(b, slug_arg)
        dst = _resolve_slug_arg(b, other_arg)
        src_slug = src['slug']
        dst_slug = dst['slug']
        if src_slug == dst_slug:
            raise SystemExit("cannot connect an item to itself")
        ts = now()
        # Move suggestion (if any) → relationships[] on src.
        src_sugs = src.get('suggested_relationships') or []
        match = next((s for s in src_sugs
                      if isinstance(s, dict) and s.get('to_slug') == dst_slug),
                     None)
        src.setdefault('relationships', [])
        if not any(isinstance(r, dict) and r.get('to_slug') == dst_slug
                   for r in src['relationships']):
            src['relationships'].append({
                'to_slug': dst_slug,
                'kind': 'related',
                'confirmed_at': ts,
                'via': via if match is None else (match.get('via') or via),
            })
        src['suggested_relationships'] = [
            s for s in src_sugs
            if not (isinstance(s, dict) and s.get('to_slug') == dst_slug)
        ]
        stamp_modified(src)
        ensure_v3_fields(src)
        # Mirror on dst.
        dst.setdefault('relationships', [])
        if not any(isinstance(r, dict) and r.get('to_slug') == src_slug
                   for r in dst['relationships']):
            dst['relationships'].append({
                'to_slug': src_slug,
                'kind': 'related',
                'confirmed_at': ts,
                'via': 'mirror',
            })
        dst['suggested_relationships'] = [
            s for s in dst.get('suggested_relationships') or []
            if not (isinstance(s, dict) and s.get('to_slug') == src_slug)
        ]
        stamp_modified(dst)
        ensure_v3_fields(dst)
        return f"{src_slug} <-> {dst_slug} connected (via={via})"

    transaction('connect', _mutate)
    print(f"connected {slug_arg} <-> {other_arg}")
    return 0


def cmd_dismiss_suggestion(a):
    """Remove a pending suggestion on both sides without promoting it.

    No-op if the suggestion doesn't exist on either side (but never errors
    — the goal is a stable "stop suggesting this" outcome).
    """
    slug_arg = a.slug
    other_arg = a.other

    def _mutate(b):
        src = _resolve_slug_arg(b, slug_arg)
        dst = _resolve_slug_arg(b, other_arg)
        src_slug = src['slug']
        dst_slug = dst['slug']
        touched = False
        src_sugs = src.get('suggested_relationships') or []
        new_src_sugs = [s for s in src_sugs
                        if not (isinstance(s, dict) and s.get('to_slug') == dst_slug)]
        if len(new_src_sugs) != len(src_sugs):
            src['suggested_relationships'] = new_src_sugs
            stamp_modified(src)
            ensure_v3_fields(src)
            touched = True
        dst_sugs = dst.get('suggested_relationships') or []
        new_dst_sugs = [s for s in dst_sugs
                        if not (isinstance(s, dict) and s.get('to_slug') == src_slug)]
        if len(new_dst_sugs) != len(dst_sugs):
            dst['suggested_relationships'] = new_dst_sugs
            stamp_modified(dst)
            ensure_v3_fields(dst)
            touched = True
        if not touched:
            return None
        return f"dismissed suggestion {src_slug} <-> {dst_slug}"

    transaction('dismiss-suggestion', _mutate)
    print(f"dismissed {slug_arg} <-> {other_arg}")
    return 0


def cmd_set_morning_brief(a):
    """Plan 09 — write ``brain.json.morning_brief`` transactionally.

    Accepts the JSON payload as the first positional argument or via
    ``--from-file PATH``. The payload must be an object with at least
    ``generated_at`` (ISO string); ``summary`` / ``generated_for_date`` /
    ``highlights`` are optional. Validation defers to ``validate_brain``
    so the same shape contract is enforced everywhere.
    """
    payload_raw = a.json_payload
    if getattr(a, 'from_file', None):
        try:
            payload_raw = Path(a.from_file).read_text(encoding='utf-8')
        except OSError as e:
            raise SystemExit(f"set-morning-brief: cannot read {a.from_file}: {e}")
    if not payload_raw:
        raise SystemExit(
            "set-morning-brief: pass a JSON payload as the first argument "
            "or use --from-file PATH"
        )
    try:
        payload = json.loads(payload_raw)
    except json.JSONDecodeError as e:
        raise SystemExit(f"set-morning-brief: not valid JSON: {e}")
    if not isinstance(payload, dict):
        raise SystemExit("set-morning-brief: payload must be a JSON object")
    if not isinstance(payload.get('generated_at'), str) or not payload['generated_at'].strip():
        raise SystemExit("set-morning-brief: payload.generated_at (ISO string) is required")

    def _mutate(b):
        b['morning_brief'] = payload
        gfd = payload.get('generated_for_date') or payload['generated_at'][:10]
        return f"morning_brief updated for {gfd}"

    transaction('set-morning-brief', _mutate)
    print(f"morning_brief updated (generated_at={payload['generated_at']})")
    return 0


def cmd_today(a):
    """Print the same lanes the Today dashboard surfaces (text form)."""
    sys.path.insert(0, str(REPO))
    import mc_pages
    b = load()
    items = b.get('items', [])
    now_dt = mc_pages.now_utc()
    print(f"=== Today · {datetime.now().strftime('%A %Y-%m-%d')} ===\n")
    # Link Triage
    triage_pending = [lt for lt in (b.get('link_triage') or [])
                      if lt.get('status') == 'unexplored']
    print(f"Link Triage queue ({len(triage_pending)}):")
    for lt in triage_pending[:6]:
        print(f"  [{lt.get('status'):<16}] {lt.get('url')}")
    print()
    # Inbox decay
    decay_items = []
    for it in items:
        lvl = mc_pages.inbox_decay_level(it, now_dt)
        if lvl:
            decay_items.append((lvl, mc_pages.inbox_decay_age(it, now_dt), it))
    decay_items.sort(key=lambda t: -(t[1] or 0))
    print(f"Inbox rot ({len(decay_items)}):")
    for lvl, age, it in decay_items[:6]:
        print(f"  [{lvl:<8}] {age}d  #{it['display_id']}  {it['title']}")
    print()
    # Need next-action
    need = [it for it in items if it.get('status') == 'Explore' and not mc_pages.item_next_action(it)]
    print(f"Explore — need next-action ({len(need)}):")
    for it in need[:6]:
        print(f"  #{it['display_id']}  {it['title']}")
    print()
    return 0



def cmd_read(a):
    import json
    if a.action == 'list':
        b = load()
        items = b.get('items') or []
        queued = [it for it in items if it.get('reading_queue') is True and it.get('phase') != 'done']
        queued.sort(key=lambda x: x.get('display_id', 0))
        if a.json:
            print(json.dumps(queued, indent=2, ensure_ascii=False))
            return 0
        for it in queued:
            link = it.get('link') or {}
            url_suffix = f" -> {link.get('url')[:60]}" if link.get('url') else ""
            print(f"#{it.get('display_id'):<4} [{it.get('category'):<16}] {it.get('title')}{url_suffix}")
        print(f"\n{len(queued)} items in reading queue.")
        return 0

    if a.action == 'add':
        target_arg = a.target
        ts = now()
        is_url_str = _looks_like_url(target_arg)

        def _mutate(b):
            items = b.setdefault('items', [])
            item = None
            if is_url_str:
                canonical = canonicalize_url(target_arg)
                item = next(
                    (it for it in items
                     if isinstance(it.get('link'), dict)
                     and (it['link'].get('canonical_url') == canonical
                          or canonicalize_url(it['link'].get('url', '')) == canonical)),
                    None,
                )
                if not item:
                    title = target_arg
                    s = slug(title); base = s; n = 2
                    taken = {x.get('slug') for x in items}
                    while s in taken:
                        s = f'{base}-{n}'; n += 1
                    nid = max([x.get('display_id', 0) for x in items] or [0]) + 1
                    item = {
                        'display_id': nid, 'slug': s, 'title': title,
                        'type': 'link',
                        'category': 'Uncategorized', 'status': 'Inbox', 'outcome': None,
                        'phase': 'inbox',
                        'reading_queue': True,
                        'source': 'read queue add',
                        'created_at': today(), 'updated_at': ts,
                        'last_modified_at': ts,
                        'sources': [{'via': 'read queue add', 'raw': target_arg, 'at': ts}],
                        'tags': [], 'project_file': None,
                        'phases': {
                            'inbox':   {'raw_capture': target_arg, 'notes': [target_arg]},
                            'explore': {'intent': None, 'open_questions': [], 'next_action': None, 'started_at': None},
                            'done':    {'outcome': None, 'summary': None, 'completed_at': None},
                        },
                        'artifacts': [],
                        'link': {
                            'url': target_arg,
                            'canonical_url': canonical,
                            'status': 'unexplored',
                            'explainer_path': f"items/{s}/explainer.html",
                            'explainer_md': None,
                            'explainer_written_at': None,
                            'domain': _domain_of(canonical),
                        },
                        'relationships': [],
                        'notes_path': None,
                        'folder_path': f"items/{s}/",
                    }
                    items.append(item)
                    _sync_link_triage_mirror(b, item)
                    try:
                        _apply_suggestions(b, s, via='auto-on-capture')
                    except Exception:
                        pass
            else:
                item = find_by_slug(b, target_arg) or find(b, _try_int(target_arg))

            if not item:
                raise SystemExit(f"no item or URL found matching '{target_arg}'")

            item['reading_queue'] = True
            stamp_modified(item)
            ensure_v3_fields(item)
            _sync_link_triage_mirror(b, item)
            detail = f"add to reading_queue #{item.get('display_id')} {item.get('slug')}"
            return detail, (item.get('slug'), item.get('display_id'))

        new_slug, nid = transaction('read.add', _mutate)
        if is_url_str:
            ensure_item_folder(new_slug)
            print(f"Captured new link #{nid} {target_arg} and added to reading queue.")
        else:
            print(f"Item #{nid} added to reading queue.")
        return 0

    if a.action == 'remove':
        slug_arg = a.id

        def _mutate(b):
            item = find_by_slug(b, slug_arg) or find(b, _try_int(slug_arg))
            if not item:
                raise SystemExit(f"no item with slug or display_id '{slug_arg}'")
            if 'reading_queue' in item:
                item['reading_queue'] = False
            stamp_modified(item)
            ensure_v3_fields(item)
            detail = f"remove from reading_queue #{item.get('display_id')}"
            return detail, item.get('display_id')

        nid = transaction('read.remove', _mutate)
        print(f"Removed item #{nid} from reading queue.")
        return 0


def run(cmd):
    return subprocess.run(cmd, cwd=str(REPO)).returncode


def parser():
    p = argparse.ArgumentParser(prog='notice', description='Notice Board CLI')
    sub = p.add_subparsers(dest='cmd', required=True)

    q = sub.add_parser('list')
    q.add_argument('--status', choices=STATUSES)
    q.add_argument('--category')
    q.add_argument('--tag'); q.add_argument('--query')
    q.add_argument('--json', action='store_true')
    q.set_defaults(func=cmd_list)

    q = sub.add_parser('show')
    q.add_argument('id', type=int); q.add_argument('--json', action='store_true')
    q.set_defaults(func=cmd_show)

    q = sub.add_parser('capture')
    q.add_argument('title')
    q.add_argument('--category', required=True)  # validated via _resolve_category
    q.add_argument('--type', choices=TYPES)      # v3.0; defaults from category
    q.add_argument('--source', default='notice CLI')
    q.add_argument('--note'); q.add_argument('--tag', action='append')
    q.set_defaults(func=cmd_capture)

    q = sub.add_parser('explore')
    q.add_argument('id', type=int); q.add_argument('--intent')
    q.add_argument('--next'); q.add_argument('--deadline')
    q.set_defaults(func=cmd_explore)

    q = sub.add_parser('next')
    q.add_argument('id', type=int); q.add_argument('text')
    q.set_defaults(func=cmd_next)

    q = sub.add_parser('done')
    q.add_argument('id', type=int)
    q.add_argument('--outcome', required=True, choices=OUTCOMES)
    q.add_argument('--summary')
    q.set_defaults(func=cmd_done)

    q = sub.add_parser('link')
    lsub = q.add_subparsers(dest='action', required=True)
    la = lsub.add_parser('add', help='attach a URL to an existing item as link_triage')
    la.add_argument('id', type=int); la.add_argument('url')
    la.add_argument('--status')  # validated via _resolve_link_status
    la.add_argument('--note')
    la.set_defaults(func=cmd_link)
    lc = lsub.add_parser('capture',
                         help='capture a raw URL into link_triage (creates placeholder item)')
    lc.add_argument('url')
    lc.add_argument('--title')
    lc.add_argument('--note')
    lc.add_argument('--source')
    lc.add_argument('--tag', action='append')
    lc.set_defaults(func=cmd_link)
    ls = lsub.add_parser('status')
    ls.add_argument('url'); ls.add_argument('status')  # validated via _resolve_link_status
    ls.add_argument('--explainer'); ls.add_argument('--note')
    ls.set_defaults(func=cmd_link)
    ll = lsub.add_parser('list')
    ll.add_argument('--status')  # validated lazily for filtering
    ll.add_argument('--json', action='store_true')
    ll.set_defaults(func=cmd_link)

    q = sub.add_parser('relate')
    q.add_argument('id_from', type=int)
    q.add_argument('type', choices=REL_TYPES)
    q.add_argument('id_to', type=int)
    q.set_defaults(func=cmd_relate)

    q = sub.add_parser(
        'set-explainer',
        help="record that items/<slug>/explainer.html is ready (used by the cron pass)",
    )
    q.add_argument('slug', help='item slug or display_id (must be type=link)')
    q.add_argument('path', help="repo-relative path, e.g. items/<slug>/explainer.html")
    q.add_argument('--written-at', dest='written_at',
                   help="ISO timestamp to record (defaults to now)")
    q.add_argument('--no-verify-path', dest='no_verify_path', action='store_true',
                   help='skip the on-disk file-existence check (tests/dry runs)')
    q.set_defaults(func=cmd_set_explainer)

    q = sub.add_parser('retype', help='change an item type without touching its category')
    q.add_argument('slug', help='item slug or display_id')
    q.add_argument('type', choices=TYPES)
    q.set_defaults(func=cmd_retype)

    q = sub.add_parser('recategorize', help='change an item category without touching its type')
    q.add_argument('slug', help='item slug or display_id')
    q.add_argument('category')  # validated via _resolve_category
    q.set_defaults(func=cmd_recategorize)

    q = sub.add_parser(
        'graduate',
        help='mark an idea/project as graduated and queue a pending '
             '~/AI/projects/<slug>/ spawn for the MacBook sync',
    )
    q.add_argument('slug', help='item slug or display_id (must be type=idea|project)')
    q.set_defaults(func=cmd_graduate)

    q = sub.add_parser(
        'ungraduate',
        help='reverse a graduation on the Notice Board side; does NOT delete '
             'the spawned ~/AI/projects/<slug>/ folder',
    )
    q.add_argument('slug', help='item slug or display_id')
    q.set_defaults(func=cmd_ungraduate)

    q = sub.add_parser(
        'confirm-graduation',
        help='called by mc-graduate-sync after the local folder lands; marks '
             'the pending_graduations[] row confirmed',
    )
    q.add_argument('slug', help='item slug or display_id')
    q.add_argument('--created-path', dest='created_path', default=None,
                   help='absolute path the MacBook sync wrote (defaults to '
                        '~/AI/projects/<slug>/)')
    q.set_defaults(func=cmd_confirm_graduation)

    q = sub.add_parser(
        'set',
        help="set an item's template_fields.<key> (per-type whitelist)",
    )
    q.add_argument('slug', help='item slug or display_id')
    q.add_argument('key', help='template field name (depends on type)')
    q.add_argument(
        'value',
        help='string value, or JSON for structured fields '
             '(stack, decisions, success_criteria, sources)',
    )
    q.set_defaults(func=cmd_set)

    q = sub.add_parser(
        'auto-connect',
        help='compute suggested_relationships for recent items',
    )
    q.add_argument('--recent-hours', dest='recent_hours', type=float, default=None,
                   help='look at items modified within the last N hours (default 24)')
    q.add_argument('--all', action='store_true',
                   help='walk every item (overrides --recent-hours)')
    q.add_argument('--threshold', type=float, default=None,
                   help=f'cosine similarity floor (default {AUTO_CONNECT_THRESHOLD})')
    q.add_argument('--top-k', dest='top_k', type=int, default=None,
                   help=f'max suggestions per item (default {AUTO_CONNECT_TOP_K})')
    q.add_argument('--via', default=None,
                   help='tag stamped on each new suggestion (default cron-auto-connect)')
    q.set_defaults(func=cmd_auto_connect)

    q = sub.add_parser(
        'compute-suggestions',
        help='compute suggested_relationships for a single item',
    )
    q.add_argument('slug', help='item slug or display_id')
    q.add_argument('--threshold', type=float, default=None)
    q.add_argument('--top-k', dest='top_k', type=int, default=None)
    q.add_argument('--via', default=None)
    q.set_defaults(func=cmd_compute_suggestions)

    q = sub.add_parser(
        'connect',
        help='promote a (pending) suggestion to a confirmed relationship',
    )
    q.add_argument('slug', help='item slug or display_id (one side of the edge)')
    q.add_argument('other', help='other item slug or display_id')
    q.add_argument('--via', choices=('auto', 'manual'), default=None,
                   help="record how this connection was confirmed "
                        "(default: 'manual', or the suggestion's stored via)")
    q.set_defaults(func=cmd_connect)

    q = sub.add_parser(
        'dismiss-suggestion',
        help='drop a pending suggestion on both sides without confirming',
    )
    q.add_argument('slug', help='item slug or display_id')
    q.add_argument('other', help='other item slug or display_id')
    q.set_defaults(func=cmd_dismiss_suggestion)

    q = sub.add_parser(
        'set-morning-brief',
        help='write brain.json.morning_brief (used by morning_brief_pass.py)',
    )
    q.add_argument('json_payload', nargs='?', default=None,
                   help='JSON object with at least generated_at; e.g. '
                        '\'{"generated_at": "...", "summary": "...", '
                        '"generated_for_date": "YYYY-MM-DD"}\'')
    q.add_argument('--from-file', dest='from_file', default=None,
                   help='read the JSON payload from a file instead of argv')
    q.set_defaults(func=cmd_set_morning_brief)

    q = sub.add_parser('today')
    q.set_defaults(func=cmd_today)

    sub.add_parser('validate').set_defaults(func=lambda a: run([sys.executable, str(VALIDATOR)]))
    sub.add_parser('render').set_defaults(func=lambda a: run([sys.executable, str(RENDER)]))
    sub.add_parser('status').set_defaults(func=lambda a: run(['git', 'status', '--short', '--branch']))

    q = sub.add_parser('read', help='manage the first-class reading queue')
    rsub = q.add_subparsers(dest='action', required=True)
    ra = rsub.add_parser('add', help='add an item or URL to the reading queue')
    ra.add_argument('target', help='display_id, slug, or raw URL')
    ra.set_defaults(func=cmd_read)
    rr = rsub.add_parser('remove', help='remove an item from the reading queue')
    rr.add_argument('id', help='display_id or slug')
    rr.set_defaults(func=cmd_read)
    rl = rsub.add_parser('list', help='list active items in the reading queue')
    rl.add_argument('--json', action='store_true')
    rl.set_defaults(func=cmd_read)


    q = sub.add_parser('publish')
    q.add_argument('-m', '--message', default='Notice Board update')
    q.set_defaults(func=lambda a: (
        print('# dry run; review then run:'),
        print(f'git -C {REPO} add -A'),
        print(f'git -C {REPO} commit -m {json.dumps(a.message)}'),
        print(f'git -C {REPO} push'),
        0,
    )[-1])
    return p


def main(argv=None):
    a = parser().parse_args(argv); return a.func(a)


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