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()