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
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,74 @@ jobs:

- name: Run tests
run: python3 -m pytest -v

# Event 177 — the distribution acceptance bar as CI: a clean macOS runner
# with nothing but Python must go wheel → install → doctor clean → sync →
# resolvable hooks → a hook actually firing, with ZERO repo checkout in
# reach of the installed package. This is the E155 reproducibility gap
# closed with evidence instead of reasoning. Also gates the privacy
# contract: a wheel that ships operator memory fails here.
clean-install:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Build wheel
run: python3 -m pip wheel . --no-deps -w dist_wheel

- name: Privacy gate — no operator memory / private skills / state in wheel
run: |
LISTING="$(python3 -m zipfile -l dist_wheel/*.whl)"
echo "$LISTING" | grep -q "_assets/core/hooks/workflow_guard.py" || { echo "assets missing from wheel"; exit 1; }
if echo "$LISTING" | grep -E "memory/global/(runtime_digest|operator_profile|agent_feedback|workflow_policy|cognitive_profile|overview|python_runtime_policy)\.md"; then
echo "PRIVACY VIOLATION: operator memory in wheel"; exit 1; fi
if echo "$LISTING" | grep -E "skills/private|memory/(evolution|substrates)|\.jsonl|\.pem|\.key|\.p12"; then
echo "PRIVACY VIOLATION: private/state/key material in wheel"; exit 1; fi

- name: Clean-home install and acceptance chain
env:
FRESH: ${{ runner.temp }}/fresh
run: |
mkdir -p "$FRESH/home"
python3 -m venv "$FRESH/venv"
"$FRESH/venv/bin/pip" install -q dist_wheel/*.whl
cd "$FRESH"
export HOME="$FRESH/home" EPISTEME_HOME="$FRESH/home/.episteme"
# doctor exits non-zero here for exactly ONE legitimate reason: the
# runner cannot have the Claude Code binary. Assert 0 warnings and
# that "missing tool: claude" is the ONLY failure — any other
# failure (or a second one) breaks the job.
"$FRESH/venv/bin/episteme" doctor | tee doctor.out || true
grep -Eq " 0 warn " doctor.out
FAILS="$(grep -E '^\s+×' doctor.out || true)"
echo "failure lines: ${FAILS:-none}"
[ "$(printf '%s' "$FAILS" | grep -c .)" -le 1 ]
if [ -n "$FAILS" ]; then echo "$FAILS" | grep -q "missing tool: claude"; fi
"$FRESH/venv/bin/episteme" sync
"$FRESH/venv/bin/python" - << 'PY'
import json, os
from pathlib import Path
cfg = json.loads((Path(os.environ["HOME"]) / ".claude/settings.json").read_text())
cmds = [h["command"] for grp in cfg.get("hooks", {}).values() for e in grp for h in e.get("hooks", [])]
script_cmds = [c for c in cmds if " " in c and c.split(" ", 1)[1].endswith(".py")]
assert script_cmds, "no hook scripts written"
missing = [c for c in script_cmds if not Path(c.split(" ", 1)[1]).exists()]
assert not missing, f"dangling hook paths: {missing}"
print(f"{len(script_cmds)} hook scripts resolve on disk")
PY
mkdir -p proj/src proj/docs && cd proj
printf '# agents\n' > AGENTS.md
printf 'Entry: `src/app.py`.\n' > docs/APP.md
printf 'pass\n' > src/app.py
git init -q && git add -A
SITE="$("$FRESH/venv/bin/python" -c 'import episteme, os; print(os.path.dirname(episteme.__file__))')"
GUARD="$SITE/_assets/core/hooks/workflow_guard.py"
OUT="$(printf '{"tool_name":"Edit","tool_input":{"file_path":"%s/proj/src/app.py"},"session_type":"main","cwd":"%s/proj"}' "$FRESH" "$FRESH" | "$FRESH/venv/bin/python" "$GUARD")"
echo "$OUT"
echo "$OUT" | grep -q "ADVISORY" || { echo "hook did not fire"; exit 1; }
29 changes: 28 additions & 1 deletion docs/SETUP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
<!-- episteme-lifecycle: status=living; reviewed_as_of=E147 -->
<!-- episteme-lifecycle: status=living; reviewed_as_of=E177 -->
# Setup — Profile, Cognition, One-Command

## Install without a checkout (E177 — the distribution path)

A pip-installed wheel is self-contained: it bundles the governance assets
(`core/hooks`, `kernel/`, `skills/`, `templates/` → `episteme/_assets/`,
built by `setup.py`; the maintainer's personal memory is excluded by
construction) and every asset consumer resolves them via
`src/episteme/_assets.py` — repo checkout when present, packaged assets
otherwise. The clean-machine chain is CI-enforced on a fresh macOS runner
(`.github/workflows/ci.yml`, `clean-install` job):

```bash
pip install <episteme wheel> # from a GitHub release artifact
episteme doctor # expect 0 warn · 0 fail
episteme sync # wires hooks from the installed assets
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.

---

Deterministic onboarding for episteme. Two axes:

- **profile** — *how work runs* (planning, testing, docs, automation).
Expand Down
59 changes: 59 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Wheel asset bundling (Event 177) — thin setuptools consumer.

The shipping/privacy contract lives in ``src/episteme/_packaging.py`` (pure,
importable without setuptools — the 3.12 CI lane has no bundled setuptools
and must still run the contract tests). This file only wires that contract
into ``build_py``: copy the governance trees into ``episteme/_assets/`` with
the privacy ignore bound to THIS repo root, so an sdist-staged build applies
identical exclusions.
"""

from __future__ import annotations

import shutil
import sys
from pathlib import Path

from setuptools import setup
from setuptools.command.build_py import build_py

REPO = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO / "src"))

from episteme._packaging import ASSET_TREES, asset_ignore_for # noqa: E402


class build_py_with_assets(build_py): # noqa: N801 (setuptools naming)
def run(self) -> None:
super().run()
ignore = asset_ignore_for(REPO)
dest_root = Path(self.build_lib) / "episteme" / "_assets"
for tree in ASSET_TREES:
src = REPO / tree
if not src.is_dir():
# FAIL LOUDLY (review): an sdist-staged build has none of the
# asset trees, and skipping silently ships an asset-less
# wheel that breaks at runtime. Wheels are built in-tree
# (`pip wheel .` — the CI clean-install path); any other
# staging must die here, visibly.
raise RuntimeError(
f"asset tree '{tree}' missing at {src} — building outside "
f"the repo checkout (sdist staging?) is not a supported "
f"wheel path; build with `pip wheel .` from the checkout"
)
dest = dest_root / tree
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest, ignore=ignore)
dest_root.mkdir(parents=True, exist_ok=True)
(dest_root / "README.txt").write_text(
"episteme governance assets, bundled at build time by setup.py.\n"
"Operator personal memory (core/memory/global live files), private\n"
"skills, and runtime state are excluded by construction — see\n"
"episteme/_packaging.py for the contract and its tests.\n",
encoding="utf-8",
)


if __name__ == "__main__":
setup(cmdclass={"build_py": build_py_with_assets})
77 changes: 77 additions & 0 deletions src/episteme/_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Asset-root resolution — repo checkout vs installed package (Event 177).

Every governance asset (core/hooks, kernel/, skills/, templates/) historically
resolved via ``Path(__file__).parents[2]`` — six independent copies of that
pattern, all of which point into ``lib/python3.X`` when the package is
pip-installed (measured: doctor's own wire probe flagged
``venv/lib/python3.11/core/hooks/... missing (wheel-shaped install?)``).
This module is the ONE definition of where assets live:

1. **Checkout wins.** Walk upward from this file looking for the checkout
markers; a dev tree (or a venv nested inside one) resolves to the repo
root exactly as before — zero behavior change for the operator.
2. **Packaged assets** otherwise: the ``episteme/_assets/`` tree that
``setup.py`` copies into the wheel (core/kernel/skills/templates, with the
operator's personal memory excluded by construction — see setup.py's
privacy ignore).
3. Legacy ``parents[2]`` as the last resort, so a broken install degrades to
the old (diagnosable) behavior rather than a new one.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Optional

#: A directory is the episteme checkout iff ALL markers exist. Two markers so
#: a stray ``kernel/`` dir in some unrelated ancestor cannot hijack resolution.
_CHECKOUT_MARKERS = ("kernel/CONSTITUTION.md", "core/hooks")


def checkout_root() -> Optional[Path]:
"""The enclosing repo checkout, or ``None`` in an installed context.

``EPISTEME_CHECKOUT`` overrides the walk (useful for tests and unusual
layouts); it must itself carry the markers to be honored — an override
pointing at a non-checkout is ignored, not trusted.
"""
override = os.environ.get("EPISTEME_CHECKOUT")
if override:
cand = Path(override)
if all((cand / m).exists() for m in _CHECKOUT_MARKERS):
return cand.resolve()
here = Path(__file__).resolve()
for parent in here.parents:
if all((parent / m).exists() for m in _CHECKOUT_MARKERS):
return parent
return None


def packaged_root() -> Optional[Path]:
"""The wheel-shipped ``_assets`` tree, or ``None`` when absent (dev tree)."""
cand = Path(__file__).resolve().parent / "_assets"
if (cand / "core" / "hooks").is_dir():
return cand
return None


def asset_root() -> Path:
"""Where ``core/``, ``kernel/``, ``skills/``, ``templates/`` live."""
root = checkout_root()
if root is not None:
return root
packaged = packaged_root()
if packaged is not None:
return packaged
return Path(__file__).resolve().parents[2]


def is_installed_context() -> bool:
"""True when running from an installed package with no checkout in reach.

The sync origin guard branches on this: an installed wheel's assets are
versioned, immutable-by-install content — a legitimate origin class,
unlike the throwaway-checkout class the guard exists to refuse.
"""
return checkout_root() is None
4 changes: 3 additions & 1 deletion src/episteme/_cognitive_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@

# Locate core/hooks/_chain.py — same lazy-import pattern other src/episteme/
# library modules use for hook-tier modules.
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
from episteme._assets import asset_root as _asset_root

_REPO_ROOT = _asset_root()
_CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks"
if str(_CORE_HOOKS_DIR) not in sys.path:
sys.path.insert(0, str(_CORE_HOOKS_DIR))
Expand Down
73 changes: 73 additions & 0 deletions src/episteme/_packaging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""The wheel's asset-shipping contract — pure logic, no setuptools (E177).

Split from setup.py because Python 3.12 venvs no longer bundle setuptools:
importing the contract for TESTING must not require the build backend (the
3.12 CI lane failed collection on exactly that). setup.py consumes these;
tests/test_wheel_assets.py pins them; neither needs the other's runtime.

PRIVACY IS STRUCTURAL HERE: ``core/memory/global`` is the operator's
personal cognitive profile — only its ``examples/`` templates ship. Private
skills, runtime ``*.jsonl`` state, caches, and key-shaped files are dropped
wherever they appear. A leak requires editing THIS file, not forgetting a
step.
"""

from __future__ import annotations

from pathlib import Path

#: Trees shipped into episteme/_assets/ (relative to the repo root).
ASSET_TREES = ("core", "kernel", "skills", "templates")

#: Directory names dropped anywhere in the copy.
DROP_DIR_NAMES = {"__pycache__", ".git", ".DS_Store", "node_modules"}

#: File suffixes dropped anywhere (runtime state, caches, key material).
DROP_SUFFIXES = (".pyc", ".jsonl", ".pem", ".key", ".p12")


def asset_ignore_for(repo_root: Path):
"""`shutil.copytree` ignore callable bound to ``repo_root``.

Binding the root explicitly (instead of assuming the copy source lives
under this file's repo) keeps the privacy rules correct no matter where
the build frontend stages the tree — an sdist temp dir must get the same
exclusions as an in-repo build.
"""
repo_root = Path(repo_root).resolve()

def _ignore(directory: str, names: list) -> set:
directory_path = Path(directory).resolve()
try:
rel = str(directory_path.relative_to(repo_root))
except ValueError:
# FAIL CLOSED (review blocker-class): an unrelatable staging dir
# would silently disable every privacy rule and ship the
# operator's memory. A build that cannot place itself must die
# loudly, never ship openly.
raise RuntimeError(
f"asset staging dir {directory_path} is not under the bound "
f"repo root {repo_root}; refusing to apply privacy rules "
f"blind"
)
ignored = set()
for name in names:
if name in DROP_DIR_NAMES or name.endswith(DROP_SUFFIXES):
ignored.add(name)
# The operator's personal memory: ship the fork-install templates ONLY.
if rel == "core/memory/global":
ignored.update(n for n in names if n != "examples")
# Runtime data lanes, mirrored from .gitignore's asset-tree rules —
# tests/test_wheel_assets.py cross-checks that every gitignored
# pattern under the shipped trees has a drop rule here, so a new
# runtime lane cannot leak by omission (review: evolution episodes
# were exactly such an omission, and only LOCAL builds could see it).
if rel == "core/memory":
ignored.add("substrates")
ignored.add("evolution")
# Private skills never ship.
if rel == "skills":
ignored.add("private")
return ignored

return _ignore
4 changes: 3 additions & 1 deletion src/episteme/_policy_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
from pathlib import Path
from typing import Iterable

_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
from episteme._assets import asset_root as _asset_root

_REPO_ROOT = _asset_root()
_CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks"
if str(_CORE_HOOKS_DIR) not in sys.path:
sys.path.insert(0, str(_CORE_HOOKS_DIR))
Expand Down
4 changes: 3 additions & 1 deletion src/episteme/_profile_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
from typing import Any, Literal, TypedDict


REPO_ROOT = Path(__file__).resolve().parents[2]
from episteme._assets import asset_root as _asset_root

REPO_ROOT = _asset_root()

# v1.0 RC CP1 — `_classify_disconfirmation` and its three pattern tuples moved
# to `core/hooks/_specificity.py` so the hot-path validator
Expand Down
4 changes: 3 additions & 1 deletion src/episteme/_profile_audit_ack.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@

# Locate core/hooks/_chain.py — same lazy-import pattern the rest of
# the CLI uses for hook-tier modules.
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
from episteme._assets import asset_root as _asset_root

_REPO_ROOT = _asset_root()
_CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks"
if str(_CORE_HOOKS_DIR) not in sys.path:
sys.path.insert(0, str(_CORE_HOOKS_DIR))
Expand Down
4 changes: 3 additions & 1 deletion src/episteme/_profile_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@

# Locate core/hooks/_chain.py — same lazy-import pattern other src/episteme/
# library modules use for hook-tier modules.
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
from episteme._assets import asset_root as _asset_root

_REPO_ROOT = _asset_root()
_CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks"
if str(_CORE_HOOKS_DIR) not in sys.path:
sys.path.insert(0, str(_CORE_HOOKS_DIR))
Expand Down
Loading
Loading