#!/usr/bin/env python3
"""test_features.py — end-to-end test of the v0.9 features against the mock OLRAPI.

Exercises the real shipped scripts (echo.py capture/resolve/recall/link + sweep.py)
through subprocess, then reads ground truth back from the mock. No network, no creds,
no live vault.

Run:  python test_features.py [--port 8801]
"""
import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error
from pathlib import Path

HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts"
ECHO = SCRIPTS / "echo.py"
SWEEP = SCRIPTS / "sweep.py"
KEY = "test-key-not-a-real-secret"

failures = []


def check(name, cond, detail=""):
    print(f"{'ok  ' if cond else 'FAIL'} {name}" + (f"  -- {detail}" if not cond else ""))
    if not cond:
        failures.append(name)


class Harness:
    def __init__(self, base):
        import tempfile
        self.base = base
        self.state_dir = tempfile.mkdtemp()

    def http(self, method, url, body=None, headers=None):
        data = body.encode() if isinstance(body, str) else body
        req = urllib.request.Request(url, data=data, method=method,
                                     headers={"Authorization": f"Bearer {KEY}", **(headers or {})})
        try:
            with urllib.request.urlopen(req, timeout=10) as r:
                return r.status, r.read().decode("utf-8", "replace")
        except urllib.error.HTTPError as e:
            return e.code, e.read().decode("utf-8", "replace")
        except Exception as e:
            return 0, str(e)

    def reset(self):
        self.http("POST", f"{self.base}/__debug__reset")

    def seed(self, path, content):
        self.http("PUT", f"{self.base}/vault/{path}", content)

    def ground(self, path):
        _, body = self.http("GET", f"{self.base}/__debug__?path={path}")
        return None if body == "<<MISSING>>" else body

    def echo(self, *args):
        # ECHO_STATE_DIR isolation matters since 2.3: the recall index is LOCAL-first,
        # and without this the suite would write into the real ~/.echo-memory.
        env = dict(os.environ, ECHO_BASE=self.base, ECHO_KEY=KEY, ECHO_VERIFY="1",
                   ECHO_TODAY="2026-06-21", ECHO_STATE_DIR=self.state_dir)
        return subprocess.run([sys.executable, str(args[0]), *args[1:]],
                              capture_output=True, text=True, env=env)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, default=8801)
    a = ap.parse_args()
    base = f"http://127.0.0.1:{a.port}"
    srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
                           stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    try:
        for _ in range(50):
            try:
                urllib.request.urlopen(f"{base}/__debug__reset", data=b"", timeout=1); break
            except Exception:
                time.sleep(0.1)
        h = Harness(base)
        h.reset()
        h.seed("_agent/echo-vault.md", "---\nschema_version: 2\n---\n# marker\n")

        # 1. capture a person (with an alias)
        r = h.echo(ECHO, "capture", "Bob Smith", "--kind", "person", "--aliases", "bob,rs")
        note = h.ground("resources/people/bob-smith.md")
        check("capture person creates note", note is not None and "type: person" in note, r.stderr)
        check("capture stamps agent_written", note and "agent_written: true" in note)
        idx = h.ground("_agent/index/entities.json")
        check("index records entity", idx and "bob-smith" in idx and '"bob"' in idx)

        # 2. resolve by alias
        r = h.echo(ECHO, "resolve", "bob")
        out = json.loads(r.stdout) if r.stdout.strip().startswith("{") else {}
        check("resolve alias -> path", out.get("match") and out.get("path") == "resources/people/bob-smith.md", r.stdout)

        # 3. capture a company whose body mentions Bob Smith -> auto bidirectional link
        r = subprocess.run([sys.executable, str(ECHO), "capture", "MPM", "--kind", "company", "-"],
                           input="Bob Smith is the principal here.\n", capture_output=True, text=True,
                           env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21"))
        mpm = h.ground("resources/companies/mpm.md")
        bob = h.ground("resources/people/bob-smith.md")
        check("auto-link forward (mpm -> bob)", mpm and "[[resources/people/bob-smith]]" in mpm, r.stdout + r.stderr)
        check("auto-link reciprocal (bob -> mpm)", bob and "[[resources/companies/mpm]]" in bob)

        # 4. recall an entity surfaces its linked neighborhood (search is mocked empty,
        #    so this exercises the index-resolve + 1-hop expansion path)
        r = h.echo(ECHO, "recall", "MPM")
        check("recall surfaces linked person", "resources/people/bob-smith.md" in r.stdout, r.stdout)

        # 4b. BM25 finds a note by a BODY term. /search/simple is mocked empty, so a hit
        #     here can only come from the local BM25 recall index (proves H1 lexical layer).
        r = h.echo(ECHO, "recall", "principal")
        check("recall finds note by body term (BM25)",
              "resources/companies/mpm.md" in r.stdout, r.stdout)

        # 5. explicit link between two fresh notes
        h.echo(ECHO, "capture", "Alpha", "--kind", "concept")
        h.echo(ECHO, "capture", "Beta", "--kind", "concept")
        h.echo(ECHO, "link", "resources/concepts/alpha.md", "resources/concepts/beta.md")
        alpha = h.ground("resources/concepts/alpha.md")
        beta = h.ground("resources/concepts/beta.md")
        check("link adds A->B", alpha and "[[resources/concepts/beta]]" in alpha)
        check("link adds B->A", beta and "[[resources/concepts/alpha]]" in beta)

        # 6. inbox capture (unknown home)
        h.echo(ECHO, "capture", "stray thought", "--inbox")
        inbox = h.ground("inbox/captures/inbox.md")
        check("inbox capture lands", inbox and "stray thought" in inbox)

        # 7. sweep --apply rebuilds the index, symmetrizes, stamps schema 3
        r = h.echo(SWEEP, "--apply")
        marker = h.ground("_agent/echo-vault.md")
        check("sweep runs clean", r.returncode == 0, r.stdout + r.stderr)
        check("sweep stamps schema 4", marker and "schema_version=4" in marker.replace(": ", "="), marker)
        idx2 = h.ground("_agent/index/entities.json")
        check("sweep rebuilt index has all entities",
              idx2 and all(s in idx2 for s in ["bob-smith", "mpm", "alpha", "beta"]))
        rix = h.ground("_agent/index/recall-index.json")
        check("sweep builds the recall (BM25) index",
              rix is not None and '"postings"' in rix and "principal" in rix, rix)

        # 8. H3 — atomic_index_update re-reads fresh and MERGES, so an entry written by a
        #    "concurrent" session survives our write; and the advisory lock is released after.
        h.http("PUT", f"{base}/vault/_agent/index/entities.json",
               '{"schema":1,"updated":"2026-06-21","entities":{"ext-entity":'
               '{"path":"resources/concepts/ext-entity.md","kind":"concept","title":"Ext",'
               '"aliases":[],"last_seen":"2026-06-21"}}}')
        env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21", PYTHONPATH=str(SCRIPTS))
        snippet = ("import echo_index as ix, echo_concurrency as c; "
                   "c.atomic_index_update(lambda d: ix.upsert(d,'mine',"
                   "'resources/concepts/mine.md','concept','Mine'))")
        subprocess.run([sys.executable, "-c", snippet], env=env, capture_output=True, text=True)
        idx3 = h.ground("_agent/index/entities.json")
        check("H3 atomic update merges a concurrent entry (no clobber)",
              bool(idx3) and "ext-entity" in idx3 and "mine" in idx3, idx3)
        check("H3 advisory lock released after the transaction",
              h.ground("_agent/locks/vault.lock") is None)

        # 9. M4 — --dry-run previews and writes nothing; --json emits a parseable envelope.
        r = h.echo(ECHO, "capture", "DryRun Co", "--kind", "company", "--dry-run", "--json")
        try:
            env = json.loads(r.stdout)
        except Exception:
            env = {}
        check("M4 --dry-run emits a dry-run JSON envelope",
              env.get("action", "").startswith("dry-run") and env.get("path") == "resources/companies/dryrun-co.md", r.stdout)
        check("M4 --dry-run writes nothing", h.ground("resources/companies/dryrun-co.md") is None)
        r = h.echo(ECHO, "capture", "Json Co", "--kind", "company", "--json")
        try:
            env = json.loads(r.stdout)
        except Exception:
            env = {}
        check("M4 --json envelope reports created + path",
              env.get("ok") is True and env.get("action") == "created"
              and env.get("path") == "resources/companies/json-co.md", r.stdout)
        check("M4 --json capture actually wrote the note", h.ground("resources/companies/json-co.md") is not None)

        # ---------------- v1.5 features ----------------------------------------
        # step 8 clobbered entities.json on purpose; rebuild the index from the notes
        # so the v1.5 tests run against a coherent vault.
        h.echo(SWEEP, "--apply")

        # 10. frontmatter completeness at write time: entity notes are born classified.
        bob2 = h.ground("resources/people/bob-smith.md") or ""
        check("v1.5 capture stamps a default status", "status: active" in bob2, bob2)
        check("v1.5 capture seeds tags with the kind", "tags: [person]" in bob2, bob2)

        # 11. update-capture preserves the FULL body (old code kept only line 1).
        r = subprocess.run([sys.executable, str(ECHO), "capture", "Bob Smith", "-", "--kind", "person"],
                           input="met at expo\nsecond line survives\nthird line too\n",
                           capture_output=True, text=True,
                           env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_TODAY="2026-06-21"))
        bob3 = h.ground("resources/people/bob-smith.md") or ""
        check("v1.5 update keeps the dated bullet", "- 2026-06-21: met at expo" in bob3, r.stdout + r.stderr)
        check("v1.5 update keeps EVERY body line",
              "  second line survives" in bob3 and "  third line too" in bob3, bob3)

        # 12. duplicate gate: same-kind + distinctive-token candidate STOPS creation...
        r = h.echo(ECHO, "capture", "Robert Smith", "--kind", "person")
        check("v1.5 duplicate gate stops the create (exit 76)", r.returncode == 76, str(r.returncode))
        check("v1.5 gate wrote nothing", h.ground("resources/people/robert-smith.md") is None)
        # ... --force overrides ...
        r = h.echo(ECHO, "capture", "Robert Smith", "--kind", "person", "--force")
        check("v1.5 --force creates past the gate",
              h.ground("resources/people/robert-smith.md") is not None, r.stdout + r.stderr)
        # 12b (1.5.1). cross-kind lookalike does NOT gate — it creates with a warning.
        r = h.echo(ECHO, "capture", "Smith Consulting", "--kind", "company")
        check("v1.5.1 cross-kind lookalike is not gated",
              r.returncode == 0 and h.ground("resources/companies/smith-consulting.md") is not None,
              str(r.returncode) + r.stdout + r.stderr)
        check("v1.5.1 cross-kind lookalike still warns",
              "similar existing" in (r.stdout + r.stderr), r.stdout + r.stderr)
        # 12c (1.5.1). a single vault-common token does NOT gate: once "acme" appears in
        # two entities, "Acme Tools" creates (with a warning) instead of blocking.
        h.echo(ECHO, "capture", "Acme", "--kind", "company")
        h.echo(ECHO, "capture", "Acme Group", "--kind", "company", "--force")
        r = h.echo(ECHO, "capture", "Acme Tools", "--kind", "company")
        check("v1.5.1 single common token does not gate",
              r.returncode == 0 and h.ground("resources/companies/acme-tools.md") is not None,
              str(r.returncode) + r.stdout + r.stderr)
        # ... and --merge-into routes the capture as an update to the canonical entity.
        r = h.echo(ECHO, "capture", "Bobby The Builder", "--kind", "person", "--merge-into", "bob-smith")
        idx4 = h.ground("_agent/index/entities.json") or ""
        check("v1.5 --merge-into updates the existing entity (mention learned as alias)",
              r.returncode == 0 and "Bobby The Builder" in idx4, r.stdout + r.stderr)
        check("v1.5 --merge-into did not spawn a new note",
              h.ground("resources/people/bobby-the-builder.md") is None)

        # 13. recall corpus now includes session logs (down-weighted, but findable).
        h.seed("_agent/sessions/2026-06-15-1200-mpm-review.md",
               "---\ntype: session-log\ncreated: 2026-06-15\nupdated: 2026-06-15\n---\n"
               "# MPM review\n\nDiscussed the kerfuffle about invoicing.\n")
        h.echo(SWEEP, "--apply")
        r = h.echo(ECHO, "recall", "kerfuffle")
        check("v1.5 recall finds a session log by body term",
              "_agent/sessions/2026-06-15-1200-mpm-review.md" in r.stdout, r.stdout)
        rix2 = h.ground("_agent/index/recall-index.json") or ""
        check("v2.3 recall snapshot is schema 3 with build stamp + content hashes",
              ('"schema": 3' in rix2 or '"schema":3' in rix2)
              and '"built"' in rix2 and '"h"' in rix2, rix2[:200])

        # 14. recency-aware ranking: same term, fresher note wins.
        h.seed("resources/concepts/old-idea.md",
               "---\ntype: concept\nstatus: active\ncreated: 2025-01-01\nupdated: 2025-01-01\n"
               "tags: [concept]\n---\n# Old Idea\n\nzeppelin research notes\n")
        h.seed("resources/concepts/new-idea.md",
               "---\ntype: concept\nstatus: active\ncreated: 2026-06-20\nupdated: 2026-06-20\n"
               "tags: [concept]\n---\n# New Idea\n\nzeppelin research notes\n")
        h.echo(SWEEP, "--apply")
        r = h.echo(ECHO, "recall", "zeppelin", "--json")
        try:
            env2 = json.loads(r.stdout)
        except Exception:
            env2 = {}
        primary = env2.get("primary") or []
        check("v1.5 recall --json emits structured hits",
              env2.get("ok") is True and len(primary) >= 2, r.stdout[:300])
        check("v1.5 fresher note outranks stale twin",
              primary and primary[0].get("path") == "resources/concepts/new-idea.md",
              json.dumps(primary[:2]))
        check("v1.5 recall hits carry updated/status metadata",
              primary and primary[0].get("updated") == "2026-06-20"
              and primary[0].get("status") == "active", json.dumps(primary[:1]))

        # 15. one-tap triage: structured list, then route with an audit trail.
        h.seed("inbox/captures/inbox.md",
               "- 2026-06-01: prefers uv over pip\n- 2026-06-20: try the new espresso place\n")
        r = h.echo(ECHO, "triage", "--list", "--json")
        try:
            tenv = json.loads(r.stdout)
        except Exception:
            tenv = {}
        items = tenv.get("items") or []
        check("v1.5 triage --list parses dated captures with ages",
              len(items) == 2 and items[0].get("age_days") == 20, r.stdout[:300])
        props = [{"title": "Prefers uv over pip", "kind": "semantic",
                  "body": "The operator prefers uv over pip for Python tooling.",
                  "line": "- 2026-06-01: prefers uv over pip", "confidence": 0.9}]
        pfile = HERE / "_triage_props.json"
        pfile.write_text(json.dumps(props), encoding="utf-8")
        r = h.echo(ECHO, "triage", str(pfile), "--apply")
        pfile.unlink()
        routed = h.ground("_agent/memory/semantic/prefers-uv-over-pip.md")
        plog = h.ground("inbox/processing-log/2026-06-21.md") or ""
        check("v1.5 triage routes the item via capture", routed is not None, r.stdout + r.stderr)
        check("v1.5 triage writes the processing-log audit line",
              "prefers uv over pip" in plog and "_agent/memory/semantic/prefers-uv-over-pip.md" in plog, plog)
        check("v1.5 triage keeps the original capture",
              "prefers uv over pip" in (h.ground("inbox/captures/inbox.md") or ""))

        # 16. lint flags incomplete entity frontmatter; sweep backfills it.
        h.seed("resources/companies/driftco.md",
               "---\ntype: company\ncreated: 2026-06-01\nupdated: 2026-06-01\ntags: []\n---\n# DriftCo\n")
        LINT = SCRIPTS / "vault_lint.py"
        r = h.echo(LINT)
        check("v1.5 lint flags missing status", "driftco.md: missing status" in r.stdout, r.stdout[-800:])
        check("v1.5 lint flags empty tags", "driftco.md: empty tags" in r.stdout, r.stdout[-800:])
        h.echo(SWEEP, "--apply")
        drift = h.ground("resources/companies/driftco.md") or ""
        # (the naive mock records frontmatter PATCHes as <fm:...> markers instead of
        # editing the YAML; the hi-fi suite proves the real fm semantics)
        check("v1.5 sweep backfills status",
              "status: active" in drift or '<fm:status="active">' in drift, drift)
        check("v1.5 sweep backfills the kind tag",
              "tags: [company]" in drift or '<fm:tags=["company"]>' in drift, drift)

        # 16b. lint blind spot (1.6.0): a seed README inside a retired tree must be
        # flagged retired-path — previously the permissive leaf-readme route matched
        # first and absolved it, so retired dirs holding only a README went unflagged.
        h.seed("archive/notes/README.md", "This folder holds archived notes.\n")
        r = h.echo(LINT)
        check("v1.6 lint flags a README inside a retired tree",
              "archive/notes/README.md: retired location" in r.stdout, r.stdout[-800:])

        # 17. hooks: stop-hook nudges once on a substantive session, then stays quiet.
        import tempfile
        tdir = Path(tempfile.mkdtemp())
        transcript = tdir / "t.jsonl"
        turns = [json.dumps({"type": "user", "message": {"content": f"user turn {i}"}}) for i in range(6)]
        transcript.write_text("\n".join(turns), encoding="utf-8")
        hook_env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_STATE_DIR=str(tdir))
        payload = json.dumps({"session_id": "s1", "transcript_path": str(transcript),
                              "stop_hook_active": False})
        HOOK_STOP = SCRIPTS / "echo_hook_stop.py"
        r = subprocess.run([sys.executable, str(HOOK_STOP)], input=payload,
                           capture_output=True, text=True, env=hook_env)
        try:
            henv = json.loads(r.stdout)
        except Exception:
            henv = {}
        check("v1.5 stop hook nudges a substantive session",
              r.returncode == 0 and henv.get("decision") == "block", r.stdout + r.stderr)
        r = subprocess.run([sys.executable, str(HOOK_STOP)], input=payload,
                           capture_output=True, text=True, env=hook_env)
        check("v1.5 stop hook fires only once per session",
              r.returncode == 0 and not r.stdout.strip(), r.stdout)
        HOOK_START = SCRIPTS / "echo_hook_session_start.py"
        r = subprocess.run([sys.executable, str(HOOK_START)],
                           input=json.dumps({"source": "startup"}),
                           capture_output=True, text=True, env=hook_env)
        try:
            senv = json.loads(r.stdout)
        except Exception:
            senv = {}
        ctx = (senv.get("hookSpecificOutput") or {}).get("additionalContext", "")
        check("v1.5 session-start hook injects the load output",
              r.returncode == 0 and "marker" in ctx and "echo-vault.md" in ctx, r.stdout[:300])
        r = subprocess.run([sys.executable, str(HOOK_START)],
                           input=json.dumps({"source": "resume"}),
                           capture_output=True, text=True, env=hook_env)
        check("v1.5 session-start hook stays quiet on resume",
              r.returncode == 0 and not r.stdout.strip(), r.stdout)

        # 18. (2.1.0) brief load: digest form, budget respected, key facts present.
        h.seed("_agent/memory/semantic/operator-preferences.md",
               "---\ntype: semantic-memory\ncreated: 2026-06-01\n---\n# Operator Preferences\n\n"
               "## Fact / Pattern\n- prefers concise output\n\n## Observations\n"
               + "\n".join(f"- 2026-06-{i:02d}: observation {i}" for i in range(1, 15)) + "\n")
        h.seed("inbox/captures/inbox.md", "- 2026-06-01: old capture\n- 2026-06-20: new capture\n")
        r = h.echo(ECHO, "load", "--brief")
        check("v2.1 brief load renders the digest header",
              "ECHO load (brief)" in r.stdout, r.stdout[:200])
        check("v2.1 brief load keeps Fact / Pattern",
              "prefers concise output" in r.stdout, r.stdout[:800])
        check("v2.1 brief load trims observations to the last 10",
              "last 10 of 14" in r.stdout and "observation 14" in r.stdout
              and "observation 2" not in r.stdout.replace("observation 2026", ""), r.stdout[-1200:])
        check("v2.1 brief load reports inbox as a count",
              "inbox: 2 capture(s), oldest 20d" in r.stdout, r.stdout[-400:])
        check("v2.1 brief load includes the marker line",
              "echo-vault.md" in r.stdout and "marker" in r.stdout, r.stdout[:300])

        # 19. (2.1.0) session-end: dry-run writes nothing; apply lands all steps with
        # the heartbeat LAST; a bad bundle aborts before any write.
        se_env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_VERIFY="1",
                      ECHO_TODAY="2026-06-21", ECHO_NOW="2359")
        bundle = {"slug": "quickwins-ship",
                  "log_body": "---\ntype: session-log\nstatus: complete\ncreated: 2026-06-21\n---\n"
                              "# Session Log\n\n## Goal\nShip the quick wins.\n",
                  "agent_log_line": "- 2026-06-21: shipped the quick wins",
                  "reflect": [{"title": "Quark Preference", "kind": "semantic",
                               "body": "The operator prefers quark.", "confidence": 0.9}]}
        bfile = HERE / "_session_bundle.json"
        bfile.write_text(json.dumps(bundle), encoding="utf-8")
        sess_path = "_agent/sessions/2026-06-21-2359-quickwins-ship.md"
        r = subprocess.run([sys.executable, str(ECHO), "session-end", str(bfile)],
                           capture_output=True, text=True, env=se_env)
        check("v2.1 session-end dry-run previews the plan",
              "dry-run" in r.stdout and sess_path in r.stdout, r.stdout)
        check("v2.1 session-end dry-run writes nothing", h.ground(sess_path) is None)
        r = subprocess.run([sys.executable, str(ECHO), "session-end", str(bfile), "--apply"],
                           capture_output=True, text=True, env=se_env)
        check("v2.1 session-end apply lands the session log",
              "Ship the quick wins" in (h.ground(sess_path) or ""), r.stdout + r.stderr)
        check("v2.1 session-end apply routes the reflect proposal",
              h.ground("_agent/memory/semantic/quark-preference.md") is not None)
        check("v2.1 session-end apply writes the heartbeat last (commit marker)",
              sess_path in (h.ground("_agent/heartbeat/last-session.md") or ""))
        daily = h.ground("journal/daily/2026-06-21.md") or ""
        check("v2.1 session-end apply appends the agent-log line",
              "shipped the quick wins" in daily, daily[-300:])
        bad = dict(bundle, slug="Bad_Slug!")
        bfile.write_text(json.dumps(bad), encoding="utf-8")
        r = subprocess.run([sys.executable, str(ECHO), "session-end", str(bfile), "--apply"],
                           capture_output=True, text=True, env=se_env)
        bfile.unlink()
        check("v2.1 session-end rejects a bad slug before any write",
              r.returncode == 2 and sess_path in (h.ground("_agent/heartbeat/last-session.md") or ""),
              r.stderr)

        # ---------------- v2.3 index train --------------------------------------
        # 20. local-first recall index: capture updates the LOCAL index with ZERO
        # vault snapshot writes — the vault copy only changes on sweep/session-end.
        snap_before = h.ground("_agent/index/recall-index.json") or ""
        r = h.echo(ECHO, "capture", "Quixotic Widget", "--kind", "concept", "-")
        r = subprocess.run([sys.executable, str(ECHO), "capture", "Quixotic Widget2", "--kind",
                            "concept", "-"], input="A gadget for quixotry deployment.\n",
                           capture_output=True, text=True,
                           env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY,
                                    ECHO_TODAY="2026-06-21", ECHO_STATE_DIR=h.state_dir))
        snap_after = h.ground("_agent/index/recall-index.json") or ""
        check("v2.3 capture does NOT write the vault snapshot", snap_before == snap_after)
        local_files = [p for p in Path(h.state_dir).glob("recall-index-*.json")]
        check("v2.3 capture maintains the LOCAL index", len(local_files) == 1,
              str(local_files))
        r = h.echo(ECHO, "recall", "quixotry")
        check("v2.3 locally-indexed capture is recallable",
              "resources/concepts/quixotic-widget2.md" in r.stdout, r.stdout[:400])

        # 21. stemming: a morphologically different query still hits (deployment ~ deploying).
        r = h.echo(ECHO, "recall", "deploying quixotry")
        check("v2.3 stemmed recall bridges inflections",
              "resources/concepts/quixotic-widget2.md" in r.stdout, r.stdout[:400])

        # 22. alias query expansion: an alias-phrased query surfaces the entity even
        # though the alias never appears in any note body.
        r = subprocess.run([sys.executable, str(ECHO), "capture", "Kappa Engine", "--kind",
                            "concept", "-", "--aliases", "turbokappa"],
                           input="Compression tuning notes for the kappa engine core.\n",
                           capture_output=True, text=True,
                           env=dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY,
                                    ECHO_TODAY="2026-06-21", ECHO_STATE_DIR=h.state_dir))
        r = h.echo(ECHO, "recall", "turbokappa compression")
        check("v2.3 alias expansion surfaces the aliased entity",
              "resources/concepts/kappa-engine.md" in r.stdout, r.stdout[:400])

        # 23. incremental fast sweep: an out-of-band edit (seeded directly, as if by
        # Obsidian) and a deletion are trued up without a full rebuild.
        h.seed("resources/concepts/quixotic-widget.md",
               "---\ntype: concept\nstatus: active\ncreated: 2026-06-21\nupdated: 2026-06-21\n"
               "tags: [concept]\n---\n# Quixotic Widget\n\nNow mentions zanzibar explicitly.\n")
        h.http("DELETE", f"{base}/vault/resources/concepts/quixotic-widget2.md")
        r = h.echo(SWEEP, "--fast", "--all-shards")
        check("v2.3 fast sweep reports its work",
              "fast-sweep:" in r.stdout and "reindexed" in r.stdout, r.stdout + r.stderr)
        r = h.echo(ECHO, "recall", "zanzibar")
        check("v2.3 fast sweep picked up the out-of-band edit",
              "resources/concepts/quixotic-widget.md" in r.stdout, r.stdout[:400])
        idx5 = h.ground("_agent/index/entities.json") or ""
        check("v2.3 fast sweep drops deleted notes from the entity index",
              "quixotic-widget2" not in idx5)
        check("v2.3 entity index carries content hashes", '"h"' in idx5, idx5[:300])

        print(f"\n{len(failures)} failure(s)" if failures else "\nall feature tests passed")
        return 1 if failures else 0
    finally:
        srv.terminate()


if __name__ == "__main__":
    raise SystemExit(main())
