diff --git a/app/src/main.rs b/app/src/main.rs index 360647d..0b4e618 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -113,11 +113,22 @@ fn main() { None => WebViewBuilder::new().with_url(&url), Some(raw) => { let msg = raw.replace('<', "<").replace('>', ">"); + // Onboarding, not a dead-end (E178): a fresh machine's first + // launch lands here — the page must carry the way out. WebViewBuilder::new().with_html(format!( - "

episteme viewer unavailable

{}

\ -

Start it manually: episteme \ - viewer, then relaunch this app.

", + "\ +

episteme is not set up yet

\ +

{}

\ +

Set up (once, in a terminal)

\ +
\
+pip install <episteme wheel>   # from the GitHub release\n\
+episteme init                  # seed YOUR memory (~/.episteme)\n\
+episteme sync                  # wire the governance hooks
\ +

Then relaunch this app. If episteme is installed \ + somewhere unusual, set EPISTEME_BIN to its path.

\ + ", msg )) } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1e0b7c8..f758441 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -31,7 +31,7 @@ Scope key: | Command | Scope | What it does | |---|---|---| -| `episteme init` | global | One-shot: seed kernel global memory from `core/memory/global/examples/*.example.md`. Only meaningful on a fresh kernel clone. | +| `episteme init` | global | One-shot memory seeding from the bundled examples. Checkout: seeds `core/memory/global/`. Installed package (E178): seeds the home lane `$EPISTEME_HOME/memory/global/` (upgrade-surviving; outranked by a checkout in resolution). Never clobbers existing personalizations. | | `episteme setup` | global | Interactive wizard that runs profile + cognition surveys end-to-end. | | `episteme profile {survey,infer,hybrid,gap,show,override,audit}` | global | Manage operator-profile axes (planning strictness, risk tolerance, testing rigor, etc.); `override` sets a per-project axis override (Event 85) and `audit ack ` acknowledges a drift verdict (Event 78). | | `episteme cognition {survey,infer,hybrid,show}` | global | Manage cognitive-style axes (dominant lens, noise signature, abstraction entry, etc.). | diff --git a/docs/SETUP.md b/docs/SETUP.md index b36c791..467af0f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -20,11 +20,16 @@ episteme viewer # live governance dashboard `episteme sync` from an installed package deploys the governance layer (hooks, skills, settings) and reports its origin as `installed package`. -Personalized memory for installed users is not wired yet — `episteme init` -refuses in installed context rather than writing into read-only wheel -assets (sync deploys the generic examples layer meanwhile; to personalize -today, clone the repo and run init from the checkout). Developing episteme -itself? A checkout always wins asset resolution — nothing changes for you. +Personalization (E178): `episteme init` seeds a writable, upgrade-surviving +memory lane at `$EPISTEME_HOME/memory/global/` from the bundled examples — +edit those files to encode YOUR posture, re-run `episteme sync`, and the +composed `~/.claude/CLAUDE.md` references your lane (resolution precedence: +checkout personal > home personal > packaged examples; re-running init +never clobbers an edited file). One conscious exception: `overview.md` is +git-tracked shared topology, so inside a checkout the repo's copy wins over +a home-lane overview — every other memory name falls through to yours. +Developing episteme itself? A checkout always wins asset resolution — +nothing changes for you. --- diff --git a/src/episteme/cli.py b/src/episteme/cli.py index 13e6fd7..45176ea 100644 --- a/src/episteme/cli.py +++ b/src/episteme/cli.py @@ -1340,11 +1340,39 @@ def _managed_skills() -> list[Path]: return sorted(skill_dirs, key=lambda p: p.name) +def _home_memory_dir() -> Path: + """The installed-user personal memory lane (E178). + + Lives under EPISTEME_HOME (default ``~/.episteme``) so it survives wheel + upgrades and reinstalls — the wheel's ``_assets`` tree is read-only and + ephemeral by contract (E177). Checkout users never need this dir; the + checkout's ``core/memory/global`` outranks it in resolution. + """ + home = os.environ.get("EPISTEME_HOME") + base = Path(home) if home else (Path.home() / ".episteme") + return base / "memory" / "global" + + def _resolve_memory_file(name: str) -> Path: - """Return the personal file if it exists, else fall back to the example in examples/.""" + """Personal memory file for ``name``, by precedence (E178): + + 1. checkout personal (``core/memory/global/.md``) — the operator's + source of truth; unchanged behavior when a checkout exists. + KNOWN EXCEPTION (E178 review): ``overview.md`` is the one personal + file that is git-TRACKED, so it exists in every fresh clone and wins + tier 1 there, shadowing a home-lane overview. It describes shared + project topology, not personal posture — whether to gitignore it for + consistency is an operator ledger decision, not silently mine. + 2. home personal (``$EPISTEME_HOME/memory/global/.md``) — the + installed-user lane, seeded by ``episteme init``. + 3. packaged example — generic fallback so sync always composes. + """ personal = GLOBAL_MEMORY_DIR / f"{name}.md" if personal.exists(): return personal + home_personal = _home_memory_dir() / f"{name}.md" + if home_personal.exists(): + return home_personal return GLOBAL_MEMORY_DIR / "examples" / f"{name}.example.md" @@ -1367,21 +1395,56 @@ def _init_memory() -> int: from episteme import _assets as _assets_mod if _assets_mod.is_installed_context(): - # E177 review finding: in installed context REPO_ROOT is the wheel's - # read-only/ephemeral _assets tree — seeding memory there either - # PermissionErrors (system installs) or evaporates on upgrade (venv). - # Honest refusal beats a broken write; the home-based memory lane for - # installed users is the E178 install-story work. - print( - "[episteme init] refused — installed-package context has no " - "writable memory root yet (the wheel's assets are read-only).\n" - " Installed sync deploys the generic governance layer from the " - "bundled examples; PERSONALIZED memory for installed users lands " - "in a coming release. To personalize today, clone the repo and " - "run init from the checkout.", - file=sys.stderr, - ) - return 2 + # E178: the installed-user lane (replaces E177's honest refusal). + # Seeds $EPISTEME_HOME/memory/global/ from the packaged examples — + # writable, upgrade-surviving, and outranked by a checkout if one + # ever appears. Existing files are skipped (same contract as the + # checkout path: re-running init never clobbers a personalization). + examples = GLOBAL_MEMORY_DIR / "examples" + dest_dir = _home_memory_dir() + try: + dest_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + print( + f"[episteme init] cannot create memory lane {dest_dir}: {exc}\n" + f" Set EPISTEME_HOME to a writable location and re-run.", + file=sys.stderr, + ) + return 1 + seeded, skipped = [], [] + for example in sorted(examples.glob("*.example.md")): + # Suffix-exact strip (review: .replace removed EVERY '.example'). + target = dest_dir / (example.name.removesuffix(".example.md") + ".md") + if target.stem == "build_story": + continue # no resolver consumes it; seeding it strands a dead file + if target.exists(): + skipped.append(target.name) + continue + try: + # Atomic per-file seed: a crash mid-write must not leave a + # truncated file the skip-existing contract then protects. + tmp = target.with_suffix(f".md.tmp.{os.getpid()}") + tmp.write_text( + example.read_text(encoding="utf-8"), encoding="utf-8" + ) + tmp.replace(target) + except OSError as exc: + print( + f"[episteme init] failed seeding {target.name}: {exc}", + file=sys.stderr, + ) + return 1 + seeded.append(target.name) + print(f"[episteme init] installed-package context — memory lane: {dest_dir}") + for name in seeded: + print(f" seeded {name}") + for name in skipped: + print(f" kept {name} (already personalized)") + if not seeded and not skipped: + print(" no example templates found in packaged assets", file=sys.stderr) + return 1 + print("Edit these files to encode YOUR posture, then run: episteme sync") + return 0 cwd = Path.cwd().resolve() memory_dir = REPO_ROOT / "core" / "memory" / "global" diff --git a/tests/test_install_memory.py b/tests/test_install_memory.py new file mode 100644 index 0000000..1f1684e --- /dev/null +++ b/tests/test_install_memory.py @@ -0,0 +1,127 @@ +"""Event 178 — installed-user memory lane: resolution precedence + init seeding.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from episteme import cli + + +class ResolveMemoryFileTests(unittest.TestCase): + def setUp(self): + self._tmp = TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + root = Path(self._tmp.name) + self.checkout = root / "checkout" / "core" / "memory" / "global" + (self.checkout / "examples").mkdir(parents=True) + self.home = root / "home" / ".episteme" + (self.checkout / "examples" / "overview.example.md").write_text("example\n") + + self._p1 = patch.object(cli, "GLOBAL_MEMORY_DIR", self.checkout) + self._p2 = patch.dict("os.environ", {"EPISTEME_HOME": str(self.home)}) + self._p1.start(); self._p2.start() + self.addCleanup(self._p1.stop); self.addCleanup(self._p2.stop) + + def test_examples_fallback_when_nothing_personal(self): + resolved = cli._resolve_memory_file("overview") + self.assertEqual(resolved.name, "overview.example.md") + + def test_home_lane_outranks_examples(self): + home_file = self.home / "memory" / "global" / "overview.md" + home_file.parent.mkdir(parents=True) + home_file.write_text("home personal\n") + self.assertEqual(cli._resolve_memory_file("overview"), home_file) + + def test_checkout_outranks_home(self): + (self.home / "memory" / "global").mkdir(parents=True) + (self.home / "memory" / "global" / "overview.md").write_text("home\n") + checkout_file = self.checkout / "overview.md" + checkout_file.write_text("checkout personal\n") + self.assertEqual(cli._resolve_memory_file("overview"), checkout_file) + + def test_real_fresh_clone_shape(self): + # Review F1/F2: a REAL fresh clone is not an empty checkout — it + # carries exactly one tracked personal file, overview.md (shared + # project topology). This pins the real shape: overview resolves to + # the checkout copy (the documented exception), while a personal- + # class name (runtime_digest, gitignored → absent in a clone) falls + # through to the user's home lane. + (self.checkout / "overview.md").write_text("tracked topology\n") + home_dir = self.home / "memory" / "global" + home_dir.mkdir(parents=True) + (home_dir / "overview.md").write_text("home overview\n") + (home_dir / "runtime_digest.md").write_text("home digest\n") + self.assertEqual( + cli._resolve_memory_file("overview"), self.checkout / "overview.md" + ) + self.assertEqual( + cli._resolve_memory_file("runtime_digest"), + home_dir / "runtime_digest.md", + ) + + def test_tracked_personal_files_are_exactly_overview(self): + # The F1 exception stays a SINGLE conscious exception: if another + # personal memory file ever becomes git-tracked, the precedence + # documentation is wrong again and this fails loudly. + import subprocess + + repo = Path(__file__).resolve().parents[1] + out = subprocess.run( + ["git", "-C", str(repo), "ls-files", "core/memory/global/*.md"], + capture_output=True, text=True, check=True, + ).stdout.split() + tracked = [ + p for p in out + if "/examples/" not in p and "/.generated/" not in p + ] + self.assertEqual(tracked, ["core/memory/global/overview.md"]) + + +class InstalledInitTests(unittest.TestCase): + def setUp(self): + self._tmp = TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + root = Path(self._tmp.name) + packaged = root / "assets" / "core" / "memory" / "global" + (packaged / "examples").mkdir(parents=True) + for name in ("overview", "operator_profile"): + (packaged / "examples" / f"{name}.example.md").write_text(f"{name} tpl\n") + self.home = root / "home" / ".episteme" + + self._p1 = patch.object(cli, "GLOBAL_MEMORY_DIR", packaged) + self._p2 = patch.dict("os.environ", {"EPISTEME_HOME": str(self.home)}) + self._p3 = patch("episteme._assets.is_installed_context", return_value=True) + for p in (self._p1, self._p2, self._p3): + p.start(); self.addCleanup(p.stop) + + def test_init_seeds_home_lane_from_packaged_examples(self): + rc = cli._init_memory() + self.assertEqual(rc, 0) + dest = self.home / "memory" / "global" + self.assertEqual( + sorted(p.name for p in dest.glob("*.md")), + ["operator_profile.md", "overview.md"], + ) + self.assertEqual((dest / "overview.md").read_text(), "overview tpl\n") + + def test_init_never_clobbers_a_personalization(self): + dest = self.home / "memory" / "global" + dest.mkdir(parents=True) + (dest / "overview.md").write_text("MY edited posture\n") + rc = cli._init_memory() + self.assertEqual(rc, 0) + self.assertEqual((dest / "overview.md").read_text(), "MY edited posture\n") + # the other template still seeds + self.assertTrue((dest / "operator_profile.md").exists()) + + def test_seeded_memory_composes_through_resolution(self): + cli._init_memory() + resolved = cli._resolve_memory_file("overview") + self.assertEqual(resolved, self.home / "memory" / "global" / "overview.md") + + +if __name__ == "__main__": + unittest.main()