diff --git a/SEAMS.md b/SEAMS.md index 6d76436..696e3fb 100644 --- a/SEAMS.md +++ b/SEAMS.md @@ -15,6 +15,7 @@ surface: `graph/plugins/registry.py` in the protoAgent repo. | `register_surface` | `nudge.py` | Lifecycle-managed due-card checker; inert at `nudge_interval_hours: 0` — the pull path (`review_next`) always works. | | `register_skill_dir` | `skills/learning-tutor/` | The tutor *policy* lives in a skill, not a pasted prompt — skill-level placement survives the long chats where mega-prompts decay. | | `register_subagent` ×2 | `subagents.py` | `review-coach` (runs one session, grades honestly) and `wiki-lint` (**read-only by tool allowlist** — curation can't edit). Trust boundaries expressed as tool lists. | +| `graph.sdk.plugin_store` | `__init__._data_dir` | Resolves the instance-scoped durable store. A missing or failed host seam raises instead of silently crossing into a global home-directory fallback. | | `graph.sdk.schedule_recurring` | `__init__._arm_crons`, `/learn` | Plugin-owned crons (`plugin:learning_wiki:*`, swept on disable — #1642). Scheduled review + weekly lint = the dream/distill pattern (ADR 0054): a cron fires a normal agent turn; no new scheduling machinery. | | `register_goal_verifier` ×2 | `goals.py` | Learning goals become ground-truthed conditions: `learning_wiki:strength` and `learning_wiki:reviews_clear` read the ledger, never the conversation. Usable from `/goal` and from watches. | | `register_watch_hook` | `goals.py` | When a `/learn` watch trips (target strength reached), the hook retires the loop — `stop_goal_loop` for sugar-armed loops, a hand `cancel_scheduled` for composed ones — and emits `goal_achieved`. Both watch-id schemes recognized. | diff --git a/__init__.py b/__init__.py index fbdbb49..f89837a 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,13 @@ def _data_dir(cfg: dict) -> Path: # manifest's v0.148 floor guarantees this seam in a real host. from graph import sdk - p = sdk.plugin_store(plugin_id="learning_wiki") + try: + p = sdk.plugin_store(plugin_id="learning_wiki") + except Exception as exc: + raise RuntimeError("learning_wiki requires an available instance-scoped plugin store") from exc + if p is None: + raise RuntimeError("learning_wiki received no instance-scoped plugin store") + p = Path(p) p.mkdir(parents=True, exist_ok=True) return p diff --git a/tests/conftest.py b/tests/conftest.py index a5d2d2a..9506189 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,9 +116,17 @@ def registry(tmp_path, monkeypatch): @pytest.fixture -def host_stub(monkeypatch): +def host_stub(monkeypatch, tmp_path): """Fake graph.* modules; returns a call-capture dict.""" - calls = {"scheduled": [], "cancelled": [], "watches": [], "metrics": [], "loops": [], "stopped_loops": []} + calls = { + "scheduled": [], + "cancelled": [], + "watches": [], + "metrics": [], + "loops": [], + "stopped_loops": [], + "plugin_stores": [], + } g = types.ModuleType("graph") goals_pkg = types.ModuleType("graph.goals") @@ -157,6 +165,10 @@ def record_metric(name, value, *, ts=None, plugin_id): calls["metrics"].append((name, value, plugin_id)) return {} + def plugin_store(*, plugin_id): + calls["plugin_stores"].append(plugin_id) + return tmp_path / "plugin-store" / plugin_id + class Knobs: def __init__(self, **kw): self.values_map: dict = {} @@ -197,6 +209,7 @@ def stop_goal_loop(*, plugin_id, loop_id): sdk.cancel_scheduled = cancel_scheduled sdk.create_watch = create_watch sdk.record_metric = record_metric + sdk.plugin_store = plugin_store sdk.Knobs = Knobs sdk.make_knob_tools = make_knob_tools sdk.start_goal_loop = start_goal_loop diff --git a/tests/test_store_path.py b/tests/test_store_path.py index 15467dd..8691c1d 100644 --- a/tests/test_store_path.py +++ b/tests/test_store_path.py @@ -3,6 +3,8 @@ import sys import types +import pytest + import learning_wiki @@ -26,6 +28,26 @@ def plugin_store(*, plugin_id): assert calls == ["learning_wiki"] +@pytest.mark.parametrize("result", [None, RuntimeError("store unavailable")]) +def test_default_store_refuses_an_unscoped_fallback(monkeypatch, result): + monkeypatch.delenv("LEARNING_WIKI_DIR", raising=False) + sdk = types.ModuleType("graph.sdk") + + def plugin_store(*, plugin_id): + if isinstance(result, Exception): + raise result + return result + + sdk.plugin_store = plugin_store + graph = types.ModuleType("graph") + graph.sdk = sdk + monkeypatch.setitem(sys.modules, "graph", graph) + monkeypatch.setitem(sys.modules, "graph.sdk", sdk) + + with pytest.raises(RuntimeError, match="instance-scoped plugin store"): + learning_wiki._data_dir({}) + + def test_config_and_env_overrides_stay_literal_and_skip_the_sdk(monkeypatch, tmp_path): environment = tmp_path / "environment" configured = tmp_path / "configured"