Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,22 @@ fn main() {
None => WebViewBuilder::new().with_url(&url),
Some(raw) => {
let msg = raw.replace('<', "&lt;").replace('>', "&gt;");
// 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!(
"<body style=\"background:#0b0e14;color:#e06c75;font:14px monospace;\
padding:40px\"><h2>episteme viewer unavailable</h2><p>{}</p>\
<p style=\"color:#7a8494\">Start it manually: <code>episteme \
viewer</code>, then relaunch this app.</p></body>",
"<body style=\"background:#0b0e14;color:#d7dde8;font:14px monospace;\
padding:40px;line-height:1.6\">\
<h2 style=\"color:#e06c75\">episteme is not set up yet</h2>\
<p style=\"color:#7a8494\">{}</p>\
<h3 style=\"color:#5ec2a6;margin-top:24px\">Set up (once, in a terminal)</h3>\
<pre style=\"background:#12161f;border:1px solid #1e2530;border-radius:8px;\
padding:14px 16px\">\
pip install &lt;episteme wheel&gt; <span style=\"color:#7a8494\"># from the GitHub release</span>\n\
episteme init <span style=\"color:#7a8494\"># seed YOUR memory (~/.episteme)</span>\n\
episteme sync <span style=\"color:#7a8494\"># wire the governance hooks</span></pre>\
<p style=\"color:#7a8494\">Then relaunch this app. If episteme is installed \
somewhere unusual, set <code style=\"color:#e0b25e\">EPISTEME_BIN</code> to its path.</p>\
</body>",
msg
))
}
Expand Down
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <audit-id>` acknowledges a drift verdict (Event 78). |
| `episteme cognition {survey,infer,hybrid,show}` | global | Manage cognitive-style axes (dominant lens, noise signature, abstraction entry, etc.). |
Expand Down
15 changes: 10 additions & 5 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
95 changes: 79 additions & 16 deletions src/episteme/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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/<name>.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"


Expand All @@ -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"
Expand Down
127 changes: 127 additions & 0 deletions tests/test_install_memory.py
Original file line number Diff line number Diff line change
@@ -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()
Loading