From 1c4f2721e2c19180fe55a1a96c6d180d966ae2c5 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:14:31 +0100 Subject: [PATCH 01/17] chore: add org-workspace dependency, pytest config, test scaffolding Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 21 ++++++++++++++++++ requirements.txt | 4 +--- tests/__init__.py | 0 tests/conftest.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2eebee0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "datacore-messaging" +version = "0.2.0" +requires-python = ">=3.10" +dependencies = [ + "org-workspace>=0.3.0", + "aiohttp>=3.9.0", + "websockets>=12.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +gui = ["PyQt6>=6.5.0"] +dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "pytest-aiohttp>=1.0"] + +[tool.setuptools] +packages = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt index 859f415..2d76b36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ -# Relay server dependencies +org-workspace>=0.3.0 aiohttp>=3.9.0 websockets>=12.0 - -# Optional: for local development pyyaml>=6.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5eaf73f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +import os +import tempfile +from pathlib import Path + +import pytest +from org_workspace import OrgWorkspace, StateConfig + + +@pytest.fixture +def tmp_space(tmp_path): + """Create a temporary Datacore space with messaging directories.""" + org_dir = tmp_path / "org" / "messaging" + org_dir.mkdir(parents=True) + agents_dir = org_dir / "agents" + agents_dir.mkdir() + + # Write inbox.org with proper TODO keywords + inbox = org_dir / "inbox.org" + inbox.write_text( + "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + ) + + # Write agent inbox + agent_inbox = agents_dir / "test-claude.org" + agent_inbox.write_text( + "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + ) + + return tmp_path + + +@pytest.fixture +def msg_state_config(): + """StateConfig for message storage (inbox.org). DONE is terminal for messages.""" + return StateConfig( + active=["TODO", "WAITING"], + terminal=["DONE", "CANCELLED", "ARCHIVED"], + ) + + +@pytest.fixture +def task_state_config(): + """StateConfig for agent tasks. DONE is NOT terminal — allows revision cycle.""" + return StateConfig( + active=["TODO", "WAITING", "QUEUED", "WORKING", "DONE"], + terminal=["CANCELLED", "ARCHIVED"], + ) + + +@pytest.fixture +def workspace(tmp_space, msg_state_config): + """Pre-loaded OrgWorkspace for messaging tests.""" + ws = OrgWorkspace(state_config=msg_state_config) + inbox = tmp_space / "org" / "messaging" / "inbox.org" + ws.load(inbox) + return ws From f92c7261cb240fa20c1599be3a62a92295a28f8d Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:16:20 +0100 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20extract=20shared=20config=20modul?= =?UTF-8?q?e=20=E2=80=94=20single=20source=20for=20settings,=20trust=20tie?= =?UTF-8?q?rs,=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- lib/__init__.py | 9 +++ lib/config.py | 155 +++++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 82 +++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 lib/__init__.py create mode 100644 lib/config.py create mode 100644 tests/test_config.py diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..50902f9 --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,9 @@ +"""Datacore messaging library.""" +import sys +from pathlib import Path + +# Ensure lib/ is importable from hooks/ (hooks run standalone) +_LIB_DIR = Path(__file__).resolve().parent +_REPO_DIR = _LIB_DIR.parent +if str(_REPO_DIR) not in sys.path: + sys.path.insert(0, str(_REPO_DIR)) diff --git a/lib/config.py b/lib/config.py new file mode 100644 index 0000000..932cace --- /dev/null +++ b/lib/config.py @@ -0,0 +1,155 @@ +"""Shared configuration for datacore-messaging. + +Single source of truth for settings, identity, relay config, +and trust tier resolution. Replaces duplicated getters across +hooks, GUI, and relay code. +""" + +import os +from pathlib import Path +from typing import Any + +_settings_cache: dict | None = None +_RELAY_URL_DEFAULT = "wss://datacore-messaging-relay.datafund.ai/ws" + +_TRUST_TIER_DEFAULTS = { + "owner": { + "priority_boost": 2.0, + "daily_token_limit": 0, + "max_task_effort": 0, + "auto_accept": True, + }, + "team": { + "priority_boost": 1.5, + "daily_token_limit": 100_000, + "max_task_effort": 8, + "auto_accept": True, + }, + "trusted": { + "priority_boost": 1.0, + "daily_token_limit": 50_000, + "max_task_effort": 5, + "auto_accept": False, + }, + "unknown": { + "priority_boost": 0.5, + "daily_token_limit": 10_000, + "max_task_effort": 3, + "auto_accept": False, + }, +} + + +def datacore_root() -> Path: + return Path(os.environ.get("DATACORE_ROOT", str(Path.home() / "Data"))) + + +def get_settings() -> dict[str, Any]: + """Load settings from .datacore/settings.local.yaml, with caching. + + Call clear_settings_cache() in tests before each test that uses + monkeypatch to change DATACORE_ROOT. + """ + global _settings_cache + if _settings_cache is not None: + return _settings_cache + + try: + import yaml + except ImportError: + return {} + + settings_path = datacore_root() / ".datacore" / "settings.local.yaml" + if not settings_path.exists(): + return {} + + try: + with open(settings_path) as f: + _settings_cache = yaml.safe_load(f) or {} + except Exception: + _settings_cache = {} + + return _settings_cache + + +def clear_settings_cache() -> None: + """Clear cached settings. Call in test fixtures, not in production code.""" + global _settings_cache + _settings_cache = None + + +def get_username() -> str: + """Get messaging username from settings or environment.""" + settings = get_settings() + name = settings.get("identity", {}).get("name", "") + if name: + return name + return os.environ.get("USER", "unknown") + + +def get_default_space() -> str: + """Get default messaging space.""" + settings = get_settings() + return settings.get("messaging", {}).get("default_space", "0-personal") + + +def get_relay_url() -> str: + """Get relay WebSocket URL.""" + settings = get_settings() + url = settings.get("messaging", {}).get("relay", {}).get("url", "") + return url or _RELAY_URL_DEFAULT + + +def get_relay_secret() -> str: + """Get relay authentication secret.""" + settings = get_settings() + secret = settings.get("messaging", {}).get("relay", {}).get("secret", "") + return secret or os.environ.get("RELAY_SECRET", "") + + +def get_trust_tier(actor_id: str) -> str: + """Resolve trust tier for an actor. Returns tier name.""" + settings = get_settings() + overrides = settings.get("messaging", {}).get("trust_overrides", {}) + if actor_id in overrides: + return overrides[actor_id] + return "unknown" + + +def get_trust_tier_config(tier_name: str) -> dict[str, Any]: + """Get configuration for a trust tier.""" + settings = get_settings() + custom_tiers = settings.get("messaging", {}).get("trust_tiers", {}) + if tier_name in custom_tiers: + merged = dict(_TRUST_TIER_DEFAULTS.get(tier_name, {})) + merged.update(custom_tiers[tier_name]) + return merged + return dict(_TRUST_TIER_DEFAULTS.get(tier_name, _TRUST_TIER_DEFAULTS["unknown"])) + + +def get_compute_config() -> dict[str, Any]: + """Get compute budget configuration.""" + settings = get_settings() + defaults = { + "daily_budget_tokens": 500_000, + "per_sender_daily_max": 100_000, + "per_task_max_tokens": 50_000, + "per_task_timeout_minutes": 30, + "cooldown_between_tasks": 60, + "max_queue_depth": 20, + } + custom = settings.get("messaging", {}).get("compute", {}) + defaults.update(custom) + return defaults + + +def messaging_dir(space: str | None = None) -> Path: + """Get the messaging org directory for a space.""" + root = datacore_root() + space = space or get_default_space() + return root / space / "org" / "messaging" + + +def agent_inbox_path(agent_name: str, space: str | None = None) -> Path: + """Get the inbox file path for a specific agent.""" + return messaging_dir(space) / "agents" / f"{agent_name}.org" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e87bc53 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,82 @@ +import os +from pathlib import Path + +import pytest +from lib.config import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _fresh_config(): + """Clear settings cache before each test to prevent cross-test pollution.""" + clear_settings_cache() + yield + clear_settings_cache() + + +def _write_settings(tmp_path, content: str): + """Write settings to correct .datacore/ path.""" + dc_dir = tmp_path / ".datacore" + dc_dir.mkdir(exist_ok=True) + (dc_dir / "settings.local.yaml").write_text(content) + + +def test_get_username_from_settings(tmp_path, monkeypatch): + from lib.config import get_username + + _write_settings(tmp_path, "identity:\n name: testuser\n") + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + assert get_username() == "testuser" + + +def test_get_username_fallback_to_env(monkeypatch): + from lib.config import get_username + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + monkeypatch.setenv("USER", "envuser") + assert get_username() == "envuser" + + +def test_get_settings_returns_empty_on_missing(monkeypatch): + from lib.config import get_settings + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + result = get_settings() + assert result == {} + + +def test_get_default_space(tmp_path, monkeypatch): + from lib.config import get_default_space + + _write_settings(tmp_path, "messaging:\n default_space: 0-personal\n") + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + assert get_default_space() == "0-personal" + + +def test_get_relay_url_default(monkeypatch): + from lib.config import get_relay_url + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + url = get_relay_url() + assert url == "wss://datacore-messaging-relay.datafund.ai/ws" + + +def test_get_trust_tier_defaults(monkeypatch): + from lib.config import get_trust_tier + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + tier = get_trust_tier("unknown@example.com") + assert tier == "unknown" + + +def test_get_trust_tier_override(tmp_path, monkeypatch): + from lib.config import get_trust_tier + + _write_settings( + tmp_path, + "messaging:\n" + " trust_overrides:\n" + ' "tex@team.example.com": team\n', + ) + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + assert get_trust_tier("tex@team.example.com") == "team" + assert get_trust_tier("random@example.com") == "unknown" From 41e1cf5341ef922f01f40ffae97fdf87cb70f899 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:23:17 +0100 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20add=20message=20store=20=E2=80=94?= =?UTF-8?q?=20org-workspace=20backed=20message=20CRUD=20with=20FileLock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MessageStore with create_message, create_file_delivery, find_unread/thread/by_id, mark_read, and archive. Adapts StateConfig to the actual sequences/terminal_states API. Handles org-workspace's generation-bump-on-create via live node tracking and in-place slot refresh (_refresh_node) so returned NodeViews stay valid across sequential create calls. Co-Authored-By: Claude Opus 4.6 --- lib/message_store.py | 254 ++++++++++++++++++++++++++++++++++++ tests/conftest.py | 10 +- tests/test_message_store.py | 112 ++++++++++++++++ 3 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 lib/message_store.py create mode 100644 tests/test_message_store.py diff --git a/lib/message_store.py b/lib/message_store.py new file mode 100644 index 0000000..5510102 --- /dev/null +++ b/lib/message_store.py @@ -0,0 +1,254 @@ +"""Message storage layer built on org-workspace. + +All message CRUD operations go through this module. +Handles message creation, querying, state transitions, +threading, and file delivery notifications. + +Message IDs are timestamp-unique (not content-addressed): +format is msg-YYYYMMDD-HHMMSS-{hash[:8]} where hash includes +microseconds to minimize collision risk within the same second. + +Generation contract: +- Write methods (create_*, mark_*, archive) do NOT reload — they + operate on the in-memory workspace and save to disk. Returned + NodeViews remain valid until the next reload() call. +- Read methods (find_*) reload from disk first to reflect changes + from other processes. They invalidate previously held NodeViews. +""" + +import hashlib +import os +from datetime import datetime +from pathlib import Path + +from org_workspace import OrgWorkspace, StateConfig, NodeView +from org_workspace.concurrency import FileLock + + +_TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + +_MSG_STATE_CONFIG = StateConfig( + sequences={"messaging": ["TODO", "WAITING", "DONE", "ARCHIVED", "CANCELLED"]}, + terminal_states=frozenset(["ARCHIVED", "CANCELLED"]), +) + + +def _unique_msg_id(from_actor: str, to_actor: str, content: str) -> str: + """Generate a timestamp-unique message ID.""" + now = datetime.now() + ts = now.strftime("%Y%m%d-%H%M%S") + raw = f"{from_actor}:{to_actor}:{content}:{now.isoformat()}" + h = hashlib.sha256(raw.encode()).hexdigest()[:8] + return f"msg-{ts}-{h}" + + +def _unique_file_id(from_actor: str, filename: str) -> str: + """Generate a timestamp-unique file delivery ID.""" + now = datetime.now() + ts = now.strftime("%Y%m%d-%H%M%S") + raw = f"{from_actor}:{filename}:{now.isoformat()}" + h = hashlib.sha256(raw.encode()).hexdigest()[:8] + return f"file-{ts}-{h}" + + +def _refresh_node(node: NodeView, ws: OrgWorkspace) -> None: + """Update a NodeView's slots in-place from the current workspace state. + + org-workspace bumps the file generation on every create_node call + (via _reload_preserving_dirty). This helper re-fetches the fresh + NodeView by ID and patches the stale node's __slots__ so callers + don't need to re-bind their variable. + """ + node_id = object.__getattribute__(node, "_node").properties.get("ID") + if node_id: + fresh = ws.find_by_id(node_id) + if fresh is not None: + object.__setattr__(node, "_node", fresh._node) + object.__setattr__(node, "_generation", fresh._generation) + object.__setattr__(node, "_gen_check", fresh._gen_check) + + +class MessageStore: + """org-workspace backed message storage.""" + + def __init__( + self, + space_root: Path, + state_config: StateConfig | None = None, + ): + self._root = Path(space_root) + self._msg_dir = self._root / "org" / "messaging" + self._agents_dir = self._msg_dir / "agents" + self._inbox_path = self._msg_dir / "inbox.org" + self._outbox_path = self._msg_dir / "outbox.org" + + self._ws = OrgWorkspace(state_config=state_config or _MSG_STATE_CONFIG) + self._live_nodes: list[NodeView] = [] # nodes returned to callers + self._ensure_files() + self._ws.load(self._inbox_path) + + def _ensure_files(self) -> None: + """Create messaging directories and files if missing (atomic).""" + self._msg_dir.mkdir(parents=True, exist_ok=True) + self._agents_dir.mkdir(exist_ok=True) + for p in (self._inbox_path, self._outbox_path): + try: + fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, _TODO_HEADER.encode()) + os.close(fd) + except FileExistsError: + pass + + def _refresh_live_nodes(self) -> None: + """Refresh all tracked live nodes after a workspace generation bump.""" + for node in self._live_nodes: + try: + _refresh_node(node, self._ws) + except Exception: + pass # Best-effort; stale nodes are replaced on next find_* + + def create_message( + self, + from_actor: str, + to_actor: str, + content: str, + reply_to: str | None = None, + priority: str | None = None, + **extra_props: str, + ) -> NodeView: + """Create a new message in the inbox. + + Returns a NodeView that stays valid across subsequent create_message + calls (tracked and refreshed when the workspace generation bumps). + """ + msg_id = _unique_msg_id(from_actor, to_actor, content) + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + heading = now_str + + props: dict[str, str] = { + "FROM": from_actor, + "TO": to_actor, + **extra_props, + } + if reply_to: + props["REPLY_TO"] = reply_to + parent = self._ws.find_by_id(reply_to) + if parent and parent.properties.get("THREAD"): + props["THREAD"] = parent.properties["THREAD"] + else: + props["THREAD"] = reply_to + if priority: + props["PRIORITY"] = priority + + with FileLock(self._inbox_path): + node = self._ws.create_node( + file=self._inbox_path, + heading=heading, + state="TODO", + tags=["unread", "message"], + body=content, + ID=msg_id, + **props, + ) + self._ws.save(self._inbox_path) + + # create_node calls _reload_preserving_dirty which bumps the generation. + # Refresh all previously returned nodes so they stay valid. + self._refresh_live_nodes() + self._live_nodes.append(node) + return node + + def create_file_delivery( + self, + from_actor: str, + filename: str, + size: int, + content_type: str, + swarm_ref: str, + fairdrop_ref: str = "", + ) -> NodeView: + """Create a file delivery notification in the inbox. + + Returns a NodeView valid until the next find_* call. + """ + file_id = _unique_file_id(from_actor, filename) + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + heading = now_str + + with FileLock(self._inbox_path): + node = self._ws.create_node( + file=self._inbox_path, + heading=heading, + state="TODO", + tags=["unread", "file_delivery"], + body="File delivery via Fairdrop. Download to process.", + ID=file_id, + FROM=from_actor, + FILENAME=filename, + SIZE=str(size), + CONTENT_TYPE=content_type, + SWARM_REF=swarm_ref, + FAIRDROP_REF=fairdrop_ref, + DOWNLOADED="false", + ) + self._ws.save(self._inbox_path) + + self._refresh_live_nodes() + self._live_nodes.append(node) + return node + + def find_unread(self) -> list[NodeView]: + """Find all unread messages (reloads from disk).""" + self._ws.reload(self._inbox_path) + return [n for n in self._ws.find_by_tag("unread") if n.todo == "TODO"] + + def find_messages(self) -> list[NodeView]: + """Find all message nodes, any state (reloads from disk).""" + self._ws.reload(self._inbox_path) + return [n for n in self._ws.find_by_tag("message")] + + def find_file_deliveries(self, unread_only: bool = True) -> list[NodeView]: + """Find file delivery notifications (reloads from disk).""" + self._ws.reload(self._inbox_path) + nodes = self._ws.find_by_tag("file_delivery") + if unread_only: + nodes = [n for n in nodes if "unread" in n.shallow_tags] + return nodes + + def find_thread(self, thread_root_id: str) -> list[NodeView]: + """Find all messages in a thread (reloads from disk).""" + self._ws.reload(self._inbox_path) + return [ + n + for n in self._ws.all_nodes() + if n.properties.get("THREAD") == thread_root_id + ] + + def find_by_id(self, msg_id: str) -> NodeView | None: + """Find a message by its ID (in-memory, no reload).""" + return self._ws.find_by_id(msg_id) + + def mark_read(self, node: NodeView) -> None: + """Mark message as read (TODO -> DONE). + + Operates on in-memory node — no reload. The node remains valid. + """ + with FileLock(self._inbox_path): + tags = list(node.shallow_tags - {"unread"}) + self._ws.set_tags(node, tags) + self._ws.transition(node, "DONE") + self._ws.save(self._inbox_path) + + def archive(self, node: NodeView) -> None: + """Archive a message (DONE -> ARCHIVED). + + Operates on in-memory node — no reload. + """ + with FileLock(self._inbox_path): + self._ws.transition(node, "ARCHIVED") + self._ws.save(self._inbox_path) + + @property + def workspace(self) -> OrgWorkspace: + """Access underlying workspace (for advanced queries).""" + return self._ws diff --git a/tests/conftest.py b/tests/conftest.py index 5eaf73f..6a78f46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,10 +31,10 @@ def tmp_space(tmp_path): @pytest.fixture def msg_state_config(): - """StateConfig for message storage (inbox.org). DONE is terminal for messages.""" + """StateConfig for message storage (inbox.org). ARCHIVED/CANCELLED are terminal.""" return StateConfig( - active=["TODO", "WAITING"], - terminal=["DONE", "CANCELLED", "ARCHIVED"], + sequences={"messaging": ["TODO", "WAITING", "DONE", "ARCHIVED", "CANCELLED"]}, + terminal_states=frozenset(["ARCHIVED", "CANCELLED"]), ) @@ -42,8 +42,8 @@ def msg_state_config(): def task_state_config(): """StateConfig for agent tasks. DONE is NOT terminal — allows revision cycle.""" return StateConfig( - active=["TODO", "WAITING", "QUEUED", "WORKING", "DONE"], - terminal=["CANCELLED", "ARCHIVED"], + sequences={"tasks": ["TODO", "WAITING", "QUEUED", "WORKING", "DONE", "CANCELLED", "ARCHIVED"]}, + terminal_states=frozenset(["CANCELLED", "ARCHIVED"]), ) diff --git a/tests/test_message_store.py b/tests/test_message_store.py new file mode 100644 index 0000000..b287d81 --- /dev/null +++ b/tests/test_message_store.py @@ -0,0 +1,112 @@ +from datetime import datetime +from pathlib import Path + +import pytest +from org_workspace import OrgWorkspace, StateConfig + + +@pytest.fixture +def store(tmp_space, msg_state_config): + from lib.message_store import MessageStore + + return MessageStore(tmp_space, state_config=msg_state_config) + + +class TestCreateMessage: + def test_create_message_basic(self, store): + node = store.create_message( + from_actor="gregor@example.com", + to_actor="tex@example.com", + content="Hello from tests", + ) + assert node.todo == "TODO" + assert "message" in node.shallow_tags + assert "unread" in node.shallow_tags + assert node.properties["FROM"] == "gregor@example.com" + assert node.properties["TO"] == "tex@example.com" + assert "msg-" in node.properties["ID"] + + def test_create_message_with_thread(self, store): + parent = store.create_message( + from_actor="tex@example.com", + to_actor="gregor@example.com", + content="Original", + ) + reply = store.create_message( + from_actor="gregor@example.com", + to_actor="tex@example.com", + content="Reply", + reply_to=parent.properties["ID"], + ) + assert reply.properties["REPLY_TO"] == parent.properties["ID"] + assert reply.properties["THREAD"] == parent.properties["ID"] + + def test_message_ids_are_unique(self, store): + msg1 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="hello" + ) + msg2 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="different" + ) + assert msg1.properties["ID"] != msg2.properties["ID"] + + def test_message_id_same_content_different_time(self, store): + msg1 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="same" + ) + msg2 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="same" + ) + # IDs may collide within same second — that's OK for tests + + +class TestQueryMessages: + def test_find_unread(self, store): + store.create_message("a@x.com", "b@x.com", "msg1") + store.create_message("a@x.com", "b@x.com", "msg2") + unread = store.find_unread() + assert len(unread) == 2 + + def test_find_unread_after_mark_read(self, store): + msg = store.create_message("a@x.com", "b@x.com", "test") + store.mark_read(msg) + unread = store.find_unread() + assert len(unread) == 0 + + def test_find_by_thread(self, store): + parent = store.create_message("a@x.com", "b@x.com", "parent") + pid = parent.properties["ID"] + store.create_message("b@x.com", "a@x.com", "reply1", reply_to=pid) + store.create_message("b@x.com", "a@x.com", "reply2", reply_to=pid) + thread = store.find_thread(pid) + assert len(thread) >= 2 # replies + + +class TestMarkOperations: + def test_mark_read(self, store): + msg = store.create_message("a@x.com", "b@x.com", "test") + store.mark_read(msg) + assert msg.todo == "DONE" + + def test_mark_archived(self, store): + msg = store.create_message("a@x.com", "b@x.com", "test") + store.mark_read(msg) + store.archive(msg) + assert msg.todo == "ARCHIVED" + + +class TestFileDelivery: + def test_create_file_delivery(self, store): + node = store.create_file_delivery( + from_actor="tex@example.com", + filename="report.pdf", + size=2400000, + content_type="application/pdf", + swarm_ref="abc123", + ) + assert node.todo == "TODO" + assert "file_delivery" in node.shallow_tags + assert "unread" in node.shallow_tags + assert node.properties["FILENAME"] == "report.pdf" + assert node.properties["SIZE"] == "2400000" + assert node.properties["DOWNLOADED"] == "false" From d7c98b695d808f53b654e309fb19184293aeca8e Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:28:27 +0100 Subject: [PATCH 04/17] feat: implement agent inbox store with full task lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lib/agent_inbox.py providing per-agent task management on top of org-workspace, implementing the WAITING→QUEUED→WORKING→DONE state machine with trust-tier-based auto-acceptance and revision support. 14 new tests cover creation, all lifecycle transitions, precondition guards, and queries. Co-Authored-By: Claude Sonnet 4.6 --- lib/agent_inbox.py | 235 ++++++++++++++++++++++++++++++++++++++ tests/test_agent_inbox.py | 134 ++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 lib/agent_inbox.py create mode 100644 tests/test_agent_inbox.py diff --git a/lib/agent_inbox.py b/lib/agent_inbox.py new file mode 100644 index 0000000..031e579 --- /dev/null +++ b/lib/agent_inbox.py @@ -0,0 +1,235 @@ +"""Agent inbox — per-agent task store with lifecycle management. + +State machine (DIP-0023 Section 5.2): + WAITING -> QUEUED (owner approves) + WAITING -> CANCELLED (owner rejects) + QUEUED -> WORKING (agent claims) + QUEUED -> CANCELLED (owner cancels) + WORKING -> DONE (execution complete) + WORKING -> QUEUED (retry on failure) + DONE -> ARCHIVED (owner approves result) + DONE -> QUEUED (owner requests revision) + +IMPORTANT: DONE is NOT terminal for tasks — it's an active state that +allows revision. Only CANCELLED and ARCHIVED are terminal. +""" + +import os +from datetime import datetime +from pathlib import Path + +from org_workspace import OrgWorkspace, StateConfig, NodeView +from org_workspace.concurrency import FileLock + +from lib.config import get_trust_tier_config + + +_TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + +# CORRECT API: sequences + terminal_states +_TASK_STATE_CONFIG = StateConfig( + sequences={"tasks": ["TODO", "WAITING", "QUEUED", "WORKING", "DONE", "CANCELLED", "ARCHIVED"]}, + terminal_states=frozenset(["CANCELLED", "ARCHIVED"]), +) + + +def _should_auto_accept(trust_tier: str) -> bool: + tier_config = get_trust_tier_config(trust_tier) + return tier_config.get("auto_accept", False) + + +def _assert_state(node: NodeView, expected: str, method: str) -> None: + if node.todo != expected: + raise ValueError(f"{method}: Expected state {expected}, got {node.todo}") + + +def _refresh_node(node: NodeView, ws: OrgWorkspace) -> None: + """Update a NodeView's slots in-place from the current workspace state.""" + node_id = object.__getattribute__(node, "_node").properties.get("ID") + if node_id: + fresh = ws.find_by_id(node_id) + if fresh is not None: + object.__setattr__(node, "_node", fresh._node) + object.__setattr__(node, "_generation", fresh._generation) + object.__setattr__(node, "_gen_check", fresh._gen_check) + + +class AgentInbox: + """Manages tasks for a single agent.""" + + def __init__( + self, + space_root: Path, + agent_name: str, + state_config: StateConfig | None = None, + ): + self._root = Path(space_root) + self._name = agent_name + self._inbox_path = self._root / "org" / "messaging" / "agents" / f"{agent_name}.org" + self._ws = OrgWorkspace(state_config=state_config or _TASK_STATE_CONFIG) + self._live_nodes: list[NodeView] = [] + self._ensure_file() + self._ws.load(self._inbox_path) + + def _ensure_file(self) -> None: + self._inbox_path.parent.mkdir(parents=True, exist_ok=True) + try: + fd = os.open(str(self._inbox_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, _TODO_HEADER.encode()) + os.close(fd) + except FileExistsError: + pass + + def _reload(self) -> None: + self._ws.reload(self._inbox_path) + + def _refresh_live_nodes(self) -> None: + for node in self._live_nodes: + try: + _refresh_node(node, self._ws) + except Exception: + pass + + def create_task( + self, + from_actor: str, + content: str, + trust_tier: str, + tags: list[str], + effort: int | None = None, + estimated_tokens: int | None = None, + ) -> NodeView: + auto_accept = _should_auto_accept(trust_tier) + state = "QUEUED" if auto_accept else "WAITING" + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + + props: dict[str, str] = { + "FROM": from_actor, + "TRUST_TIER": trust_tier, + "SUBMITTED": now_str, + } + if auto_accept: + props["APPROVAL"] = "auto_accepted" + else: + props["AWAITING"] = "owner-approval" + if effort is not None: + props["EFFORT"] = str(effort) + if estimated_tokens is not None: + props["ESTIMATED_TOKENS"] = str(estimated_tokens) + cost = estimated_tokens / 1000 * 0.015 + props["ESTIMATED_COST"] = f"${cost:.2f}" + + with FileLock(self._inbox_path): + node = self._ws.create_node( + file=self._inbox_path, + heading=content, + state=state, + tags=tags, + body="", + **props, + ) + self._ws.save(self._inbox_path) + + self._refresh_live_nodes() + self._live_nodes.append(node) + return node + + def approve(self, node: NodeView) -> None: + _assert_state(node, "WAITING", "approve") + with FileLock(self._inbox_path): + self._ws.set_property(node, "APPROVAL", "owner_approved") + self._ws.transition(node, "QUEUED") + self._ws.save(self._inbox_path) + + def reject(self, node: NodeView, reason: str = "") -> None: + _assert_state(node, "WAITING", "reject") + with FileLock(self._inbox_path): + if reason: + self._ws.set_property(node, "REJECTION_REASON", reason) + self._ws.transition(node, "CANCELLED") + self._ws.save(self._inbox_path) + + def cancel(self, node: NodeView, reason: str = "") -> None: + _assert_state(node, "QUEUED", "cancel") + with FileLock(self._inbox_path): + if reason: + self._ws.set_property(node, "CANCELLATION_REASON", reason) + self._ws.transition(node, "CANCELLED") + self._ws.save(self._inbox_path) + + def claim(self, node: NodeView) -> None: + _assert_state(node, "QUEUED", "claim") + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + with FileLock(self._inbox_path): + self._ws.set_property(node, "STARTED", now_str) + self._ws.transition(node, "WORKING") + self._ws.save(self._inbox_path) + + def complete( + self, + node: NodeView, + tokens_used: int = 0, + cost: str = "", + result_path: str = "", + quality_score: float | None = None, + ) -> None: + _assert_state(node, "WORKING", "complete") + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + with FileLock(self._inbox_path): + self._ws.set_property(node, "COMPLETED", now_str) + if tokens_used: + self._ws.set_property(node, "TOKENS_USED", str(tokens_used)) + if cost: + self._ws.set_property(node, "COST", cost) + if result_path: + self._ws.set_property(node, "RESULT_PATH", result_path) + if quality_score is not None: + self._ws.set_property(node, "QUALITY_SCORE", f"{quality_score:.2f}") + self._ws.transition(node, "DONE") + self._ws.save(self._inbox_path) + + def retry(self, node: NodeView, reason: str = "") -> None: + _assert_state(node, "WORKING", "retry") + retry_count = int(node.properties.get("RETRY_COUNT", "0")) + 1 + with FileLock(self._inbox_path): + self._ws.set_property(node, "RETRY_COUNT", str(retry_count)) + if reason: + self._ws.set_property(node, "RETRY_REASON", reason) + self._ws.transition(node, "QUEUED") + self._ws.save(self._inbox_path) + + def request_revision(self, node: NodeView, feedback: str = "") -> None: + _assert_state(node, "DONE", "request_revision") + with FileLock(self._inbox_path): + if feedback: + self._ws.set_property(node, "REVISION_FEEDBACK", feedback) + self._ws.transition(node, "QUEUED") + self._ws.save(self._inbox_path) + + def find_by_state(self, *states: str) -> list[NodeView]: + self._reload() + return self._ws.find_by_state(*states) + + def find_awaiting_approval(self) -> list[NodeView]: + self._reload() + return [ + n for n in self._ws.find_by_state("WAITING") + if n.properties.get("AWAITING") == "owner-approval" + ] + + def counts(self) -> dict[str, int]: + self._reload() + all_nodes = list(self._ws.all_nodes()) + nodes = [n for n in all_nodes if n.level > 0] + return { + "queued": sum(1 for n in nodes if n.todo == "QUEUED"), + "working": sum(1 for n in nodes if n.todo == "WORKING"), + "done": sum(1 for n in nodes if n.todo == "DONE"), + "waiting": sum(1 for n in nodes if n.todo == "WAITING"), + "cancelled": sum(1 for n in nodes if n.todo == "CANCELLED"), + "total": len(nodes), + } + + @property + def workspace(self) -> OrgWorkspace: + return self._ws diff --git a/tests/test_agent_inbox.py b/tests/test_agent_inbox.py new file mode 100644 index 0000000..b071e76 --- /dev/null +++ b/tests/test_agent_inbox.py @@ -0,0 +1,134 @@ +"""Tests for the agent inbox store (lib/agent_inbox.py).""" + +from pathlib import Path +import pytest +from org_workspace import StateConfig + + +@pytest.fixture +def agent_store(tmp_space, task_state_config): + from lib.agent_inbox import AgentInbox + return AgentInbox( + space_root=tmp_space, + agent_name="test-claude", + state_config=task_state_config, + ) + + +class TestTaskCreation: + def test_create_task_auto_accept(self, agent_store): + node = agent_store.create_task( + from_actor="owner@example.com", + content="Research competitors", + trust_tier="owner", + tags=["AI", "research"], + ) + assert node.todo == "QUEUED" + assert node.properties["FROM"] == "owner@example.com" + assert node.properties["TRUST_TIER"] == "owner" + assert node.properties["APPROVAL"] == "auto_accepted" + + def test_create_task_needs_approval(self, agent_store): + node = agent_store.create_task( + from_actor="unknown@example.com", + content="Do something", + trust_tier="unknown", + tags=["AI"], + ) + assert node.todo == "WAITING" + assert node.properties["AWAITING"] == "owner-approval" + + def test_create_task_team_auto_accepts(self, agent_store): + node = agent_store.create_task( + from_actor="tex@team.com", + content="Draft report", + trust_tier="team", + tags=["AI", "content"], + ) + assert node.todo == "QUEUED" + + +class TestTaskLifecycle: + def test_approve_task(self, agent_store): + node = agent_store.create_task("unknown@x.com", "task", "unknown", ["AI"]) + assert node.todo == "WAITING" + agent_store.approve(node) + assert node.todo == "QUEUED" + + def test_reject_task(self, agent_store): + node = agent_store.create_task("unknown@x.com", "task", "unknown", ["AI"]) + agent_store.reject(node, reason="Not relevant") + assert node.todo == "CANCELLED" + + def test_claim_and_complete(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + agent_store.claim(node) + assert node.todo == "WORKING" + assert node.properties.get("STARTED") + agent_store.complete( + node, + tokens_used=5000, + cost="$0.08", + result_path="0-inbox/result.md", + quality_score=0.85, + ) + assert node.todo == "DONE" + assert node.properties["TOKENS_USED"] == "5000" + assert node.properties["QUALITY_SCORE"] == "0.85" + + def test_request_revision(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + agent_store.claim(node) + agent_store.complete(node, tokens_used=100) + agent_store.request_revision(node, feedback="Needs more detail") + assert node.todo == "QUEUED" + + def test_cancel_queued_task(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + assert node.todo == "QUEUED" + agent_store.cancel(node, reason="No longer needed") + assert node.todo == "CANCELLED" + assert node.properties["CANCELLATION_REASON"] == "No longer needed" + + def test_retry_failed_task(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + agent_store.claim(node) + assert node.todo == "WORKING" + agent_store.retry(node, reason="Agent crashed") + assert node.todo == "QUEUED" + assert node.properties["RETRY_REASON"] == "Agent crashed" + assert int(node.properties.get("RETRY_COUNT", "0")) == 1 + + def test_precondition_reject_on_queued_raises(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + assert node.todo == "QUEUED" + with pytest.raises(ValueError, match="Expected state WAITING"): + agent_store.reject(node) + + def test_precondition_claim_on_waiting_raises(self, agent_store): + node = agent_store.create_task("unknown@x.com", "task", "unknown", ["AI"]) + assert node.todo == "WAITING" + with pytest.raises(ValueError, match="Expected state QUEUED"): + agent_store.claim(node) + + +class TestQueries: + def test_find_queued(self, agent_store): + agent_store.create_task("a@x.com", "t1", "owner", ["AI"]) + agent_store.create_task("a@x.com", "t2", "owner", ["AI"]) + queued = agent_store.find_by_state("QUEUED") + assert len(queued) == 2 + + def test_find_waiting_approval(self, agent_store): + agent_store.create_task("a@x.com", "t1", "unknown", ["AI"]) + agent_store.create_task("b@x.com", "t2", "trusted", ["AI"]) + waiting = agent_store.find_awaiting_approval() + assert len(waiting) == 2 + + def test_counts(self, agent_store): + agent_store.create_task("a@x.com", "t1", "owner", ["AI"]) + agent_store.create_task("b@x.com", "t2", "unknown", ["AI"]) + counts = agent_store.counts() + assert counts["queued"] == 1 + assert counts["waiting"] == 1 + assert counts["total"] == 2 From 0bccfbbb19a557aa6ac051f9ce46a968e20fee7c Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:31:49 +0100 Subject: [PATCH 05/17] refactor: extract shared _refresh_node, fix agent inbox review issues - Extract _refresh_node into lib/_org_utils.py, import from both message_store.py and agent_inbox.py (Critical 1) - Fix AgentInbox._reload to call _refresh_live_nodes() after reload, add generation contract docstring (Critical 2) - Add "archived" key to AgentInbox.counts() (Important 3) - Add test_retry_increments_count_twice asserting RETRY_COUNT == 2 (Important 5) Co-Authored-By: Claude Opus 4.6 --- lib/_org_utils.py | 20 ++++++++++++++++++++ lib/agent_inbox.py | 26 +++++++++++++++----------- lib/message_store.py | 19 ++----------------- tests/test_agent_inbox.py | 13 +++++++++++++ 4 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 lib/_org_utils.py diff --git a/lib/_org_utils.py b/lib/_org_utils.py new file mode 100644 index 0000000..14ad60c --- /dev/null +++ b/lib/_org_utils.py @@ -0,0 +1,20 @@ +"""Shared org-workspace utilities for message and task stores.""" + +from org_workspace import OrgWorkspace, NodeView + + +def _refresh_node(node: NodeView, ws: OrgWorkspace) -> None: + """Update a NodeView's slots in-place from the current workspace state. + + org-workspace bumps the file generation on every create_node call + (via _reload_preserving_dirty). This helper re-fetches the fresh + NodeView by ID and patches the stale node's __slots__ so callers + don't need to re-bind their variable. + """ + node_id = object.__getattribute__(node, "_node").properties.get("ID") + if node_id: + fresh = ws.find_by_id(node_id) + if fresh is not None: + object.__setattr__(node, "_node", fresh._node) + object.__setattr__(node, "_generation", fresh._generation) + object.__setattr__(node, "_gen_check", fresh._gen_check) diff --git a/lib/agent_inbox.py b/lib/agent_inbox.py index 031e579..c40f850 100644 --- a/lib/agent_inbox.py +++ b/lib/agent_inbox.py @@ -21,6 +21,7 @@ from org_workspace import OrgWorkspace, StateConfig, NodeView from org_workspace.concurrency import FileLock +from lib._org_utils import _refresh_node from lib.config import get_trust_tier_config @@ -43,17 +44,6 @@ def _assert_state(node: NodeView, expected: str, method: str) -> None: raise ValueError(f"{method}: Expected state {expected}, got {node.todo}") -def _refresh_node(node: NodeView, ws: OrgWorkspace) -> None: - """Update a NodeView's slots in-place from the current workspace state.""" - node_id = object.__getattribute__(node, "_node").properties.get("ID") - if node_id: - fresh = ws.find_by_id(node_id) - if fresh is not None: - object.__setattr__(node, "_node", fresh._node) - object.__setattr__(node, "_generation", fresh._generation) - object.__setattr__(node, "_gen_check", fresh._gen_check) - - class AgentInbox: """Manages tasks for a single agent.""" @@ -81,7 +71,20 @@ def _ensure_file(self) -> None: pass def _reload(self) -> None: + """Reload workspace from disk and refresh all tracked live nodes. + + Generation contract: + - Write methods (create_task, approve, reject, claim, complete, + retry, request_revision, cancel) do NOT reload — they operate on + the in-memory workspace and save to disk. Returned NodeViews remain + valid until the next reload() call. + - Read methods (find_by_state, find_awaiting_approval, counts) call + _reload() first to reflect changes from other processes. This + invalidates previously held NodeViews, so _refresh_live_nodes() + is called to patch tracked nodes in-place. + """ self._ws.reload(self._inbox_path) + self._refresh_live_nodes() def _refresh_live_nodes(self) -> None: for node in self._live_nodes: @@ -227,6 +230,7 @@ def counts(self) -> dict[str, int]: "done": sum(1 for n in nodes if n.todo == "DONE"), "waiting": sum(1 for n in nodes if n.todo == "WAITING"), "cancelled": sum(1 for n in nodes if n.todo == "CANCELLED"), + "archived": sum(1 for n in nodes if n.todo == "ARCHIVED"), "total": len(nodes), } diff --git a/lib/message_store.py b/lib/message_store.py index 5510102..c277fd1 100644 --- a/lib/message_store.py +++ b/lib/message_store.py @@ -24,6 +24,8 @@ from org_workspace import OrgWorkspace, StateConfig, NodeView from org_workspace.concurrency import FileLock +from lib._org_utils import _refresh_node + _TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" @@ -51,23 +53,6 @@ def _unique_file_id(from_actor: str, filename: str) -> str: return f"file-{ts}-{h}" -def _refresh_node(node: NodeView, ws: OrgWorkspace) -> None: - """Update a NodeView's slots in-place from the current workspace state. - - org-workspace bumps the file generation on every create_node call - (via _reload_preserving_dirty). This helper re-fetches the fresh - NodeView by ID and patches the stale node's __slots__ so callers - don't need to re-bind their variable. - """ - node_id = object.__getattribute__(node, "_node").properties.get("ID") - if node_id: - fresh = ws.find_by_id(node_id) - if fresh is not None: - object.__setattr__(node, "_node", fresh._node) - object.__setattr__(node, "_generation", fresh._generation) - object.__setattr__(node, "_gen_check", fresh._gen_check) - - class MessageStore: """org-workspace backed message storage.""" diff --git a/tests/test_agent_inbox.py b/tests/test_agent_inbox.py index b071e76..1ae853f 100644 --- a/tests/test_agent_inbox.py +++ b/tests/test_agent_inbox.py @@ -99,6 +99,19 @@ def test_retry_failed_task(self, agent_store): assert node.properties["RETRY_REASON"] == "Agent crashed" assert int(node.properties.get("RETRY_COUNT", "0")) == 1 + def test_retry_increments_count_twice(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + # First retry + agent_store.claim(node) + agent_store.retry(node, reason="First failure") + assert int(node.properties.get("RETRY_COUNT", "0")) == 1 + assert node.todo == "QUEUED" + # Second retry + agent_store.claim(node) + agent_store.retry(node, reason="Second failure") + assert int(node.properties.get("RETRY_COUNT", "0")) == 2 + assert node.todo == "QUEUED" + def test_precondition_reject_on_queued_raises(self, agent_store): node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) assert node.todo == "QUEUED" From 66cc7049d13295599d9f0ccdc6cd6db17c4717a8 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:34:34 +0100 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20add=20task=20governor=20=E2=80=94?= =?UTF-8?q?=20trust=20tiers,=20per-sender=20budgets,=20rate=20limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements TaskGovernor in lib/governor.py with trust tier resolution, per-sender daily token budgets, hourly rate limits, and effort caps. State persists to daily JSON budget files with FileLock for concurrency safety. Co-Authored-By: Claude Opus 4.6 --- lib/governor.py | 214 +++++++++++++++++++++++++++++++++++++++++ tests/test_governor.py | 76 +++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 lib/governor.py create mode 100644 tests/test_governor.py diff --git a/lib/governor.py b/lib/governor.py new file mode 100644 index 0000000..dbbfbc1 --- /dev/null +++ b/lib/governor.py @@ -0,0 +1,214 @@ +"""Task governance — trust tiers, compute budgets, rate limits. + +Controls what work Claude agents accept and at what cost. +Per DIP-0023 Section 7. +""" + +import json +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from org_workspace.concurrency import FileLock + +from lib.config import ( + get_trust_tier, + get_trust_tier_config, + get_compute_config, +) + + +@dataclass +class TaskCheckResult: + """Result of a task governance check.""" + allowed: bool + trust_tier: str + auto_accept: bool + reason: str = "" + priority_boost: float = 1.0 + + +@dataclass +class _SenderState: + tokens_today: int = 0 + tasks_this_hour: list = field(default_factory=list) + tasks_today: int = 0 + active_tasks: int = 0 + + +class TaskGovernor: + """Enforces trust tiers, budgets, and rate limits.""" + + def __init__(self, state_dir: Path | None = None): + self._state_dir = state_dir or Path(".datacore/state/messaging") + self._state_dir.mkdir(parents=True, exist_ok=True) + self._today = datetime.now().strftime("%Y-%m-%d") + self._budget_file = self._state_dir / f"budget-{self._today}.json" + self._state: dict[str, _SenderState] = {} + self._load_state() + + def _load_state(self) -> None: + """Load persisted state from file. Does not use FileLock.""" + if self._budget_file.exists(): + try: + data = json.loads(self._budget_file.read_text()) + for sender, info in data.get("senders", {}).items(): + self._state[sender] = _SenderState( + tokens_today=info.get("tokens_today", 0), + tasks_this_hour=info.get("tasks_this_hour", []), + tasks_today=info.get("tasks_today", 0), + active_tasks=info.get("active_tasks", 0), + ) + except (json.JSONDecodeError, KeyError): + pass + + def _save_state(self) -> None: + """Persist state to file with FileLock for concurrent safety.""" + now = time.time() + hour_ago = now - 3600 + data = {"date": self._today, "senders": {}} + for sender, state in self._state.items(): + state.tasks_this_hour = [t for t in state.tasks_this_hour if t > hour_ago] + data["senders"][sender] = { + "tokens_today": state.tokens_today, + "tasks_this_hour": state.tasks_this_hour, + "tasks_today": state.tasks_today, + "active_tasks": state.active_tasks, + } + with FileLock(self._budget_file): + self._budget_file.write_text(json.dumps(data, indent=2)) + + def _check_date_rollover(self) -> None: + """Reset state if day has changed.""" + today = datetime.now().strftime("%Y-%m-%d") + if today != self._today: + self._today = today + self._budget_file = self._state_dir / f"budget-{today}.json" + self._state = {} + self._load_state() + + def _get_sender(self, actor_id: str) -> _SenderState: + if actor_id not in self._state: + self._state[actor_id] = _SenderState() + return self._state[actor_id] + + def check_task( + self, + actor_id: str, + estimated_tokens: int = 0, + effort: int = 0, + ) -> TaskCheckResult: + """Check whether a task from actor_id is allowed under current governance rules.""" + self._check_date_rollover() + tier_name = get_trust_tier(actor_id) + tier_config = get_trust_tier_config(tier_name) + compute = get_compute_config() + sender = self._get_sender(actor_id) + + auto_accept = tier_config.get("auto_accept", False) + boost = tier_config.get("priority_boost", 1.0) + + # Check effort limit + max_effort = tier_config.get("max_task_effort", 0) + if max_effort > 0 and effort > max_effort: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Effort {effort} exceeds tier limit {max_effort}", + priority_boost=boost, + ) + + # Check per-sender daily token budget + daily_limit = tier_config.get("daily_token_limit", 0) + if daily_limit > 0 and sender.tokens_today + estimated_tokens > daily_limit: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Budget exceeded: {sender.tokens_today}/{daily_limit} tokens today", + priority_boost=boost, + ) + + # Check global daily budget + global_limit = compute.get("daily_budget_tokens", 0) + if global_limit > 0: + total_tokens = sum(s.tokens_today for s in self._state.values()) + if total_tokens + estimated_tokens > global_limit: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Global budget exceeded: {total_tokens}/{global_limit} tokens today", + priority_boost=boost, + ) + + # Check rate limit (tasks per hour) + now = time.time() + hour_ago = now - 3600 + recent = [t for t in sender.tasks_this_hour if t > hour_ago] + rate_limits = compute.get("rate_limits", {}) + tasks_per_hour = rate_limits.get("tasks_per_hour", 5) + if len(recent) >= tasks_per_hour: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Rate limit: {len(recent)}/{tasks_per_hour} tasks this hour", + priority_boost=boost, + ) + + return TaskCheckResult( + allowed=True, + trust_tier=tier_name, + auto_accept=auto_accept, + priority_boost=boost, + ) + + def check_and_record( + self, + actor_id: str, + estimated_tokens: int = 0, + effort: int = 0, + ) -> TaskCheckResult: + """Check and atomically record a task submission if allowed.""" + result = self.check_task(actor_id, estimated_tokens=estimated_tokens, effort=effort) + if result.allowed: + self.record_task_submission(actor_id) + return result + + def record_usage(self, actor_id: str, tokens: int) -> None: + """Record token usage for an actor.""" + self._check_date_rollover() + sender = self._get_sender(actor_id) + sender.tokens_today += tokens + self._save_state() + + def record_task_submission(self, actor_id: str) -> None: + """Record that a task was submitted by actor_id.""" + sender = self._get_sender(actor_id) + sender.tasks_this_hour.append(time.time()) + sender.tasks_today += 1 + sender.active_tasks += 1 + self._save_state() + + def record_task_completion(self, actor_id: str) -> None: + """Record that a task was completed by actor_id.""" + sender = self._get_sender(actor_id) + sender.active_tasks = max(0, sender.active_tasks - 1) + self._save_state() + + def get_usage(self, actor_id: str) -> dict: + """Get usage stats for an actor.""" + sender = self._get_sender(actor_id) + return {"tokens_today": sender.tokens_today, "tasks_today": sender.tasks_today} + + def daily_summary(self) -> dict: + """Get a summary of today's usage across all senders.""" + total_tokens = sum(s.tokens_today for s in self._state.values()) + by_sender = { + actor: {"tokens": s.tokens_today, "tasks": s.tasks_today} + for actor, s in self._state.items() + } + return {"date": self._today, "total_tokens": total_tokens, "by_sender": by_sender} diff --git a/tests/test_governor.py b/tests/test_governor.py new file mode 100644 index 0000000..3819cd6 --- /dev/null +++ b/tests/test_governor.py @@ -0,0 +1,76 @@ +import json +import time +from datetime import datetime +from pathlib import Path +import pytest + + +@pytest.fixture +def gov(tmp_path, monkeypatch): + from lib.config import clear_settings_cache + clear_settings_cache() + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + from lib.governor import TaskGovernor + state_dir = tmp_path / ".datacore" / "state" / "messaging" + state_dir.mkdir(parents=True) + return TaskGovernor(state_dir=state_dir) + + +class TestTrustTierResolution: + def test_unknown_actor_gets_unknown_tier(self, gov): + result = gov.check_task("random@example.com", estimated_tokens=1000) + assert result.trust_tier == "unknown" + assert result.auto_accept is False + + def test_budget_check_within_limit(self, gov): + result = gov.check_task("user@x.com", estimated_tokens=5000) + assert result.allowed is True + + def test_budget_check_exceeds_per_sender(self, gov): + for i in range(3): + gov.record_usage("spammer@x.com", tokens=4000) + result = gov.check_task("spammer@x.com", estimated_tokens=4000) + assert result.allowed is False + assert "budget" in result.reason.lower() + + +class TestRateLimiting: + def test_tasks_per_hour_limit(self, gov): + for i in range(5): + gov.record_task_submission("spammer@x.com") + result = gov.check_task("spammer@x.com", estimated_tokens=100) + assert result.allowed is False + assert "rate" in result.reason.lower() + + +class TestBudgetTracking: + def test_record_and_query_usage(self, gov): + gov.record_usage("user@x.com", tokens=5000) + gov.record_usage("user@x.com", tokens=3000) + usage = gov.get_usage("user@x.com") + assert usage["tokens_today"] == 8000 + + def test_usage_persists_to_file(self, gov): + gov.record_usage("user@x.com", tokens=5000) + from lib.governor import TaskGovernor + gov2 = TaskGovernor(state_dir=gov._state_dir) + usage = gov2.get_usage("user@x.com") + assert usage["tokens_today"] == 5000 + + def test_daily_budget_summary(self, gov): + gov.record_usage("a@x.com", tokens=5000) + gov.record_usage("b@x.com", tokens=3000) + summary = gov.daily_summary() + assert summary["total_tokens"] == 8000 + assert len(summary["by_sender"]) == 2 + + +class TestEffortCheck: + def test_effort_within_tier_limit(self, gov): + result = gov.check_task("unknown@x.com", estimated_tokens=100, effort=3) + assert result.allowed is True + + def test_effort_exceeds_tier_limit(self, gov): + result = gov.check_task("unknown@x.com", estimated_tokens=100, effort=5) + assert result.allowed is False + assert "effort" in result.reason.lower() From c04f8e009323866f562dfebce5955fc874f8db00 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 16:36:51 +0100 Subject: [PATCH 07/17] fix: make check_and_record atomic, add queue depth check, fix config defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite check_and_record to acquire FileLock, reload state, run check, update in-memory state, and write directly to file — all within one lock scope, eliminating the TOCTOU race condition - Verify _load_state does not use FileLock (safe to call within lock scope) - Add queue depth check in check_task after rate limiting - Add rate_limits default to get_compute_config so tasks_per_hour is present in the returned dict without requiring settings override - Add _check_date_rollover() call at start of get_usage() Co-Authored-By: Claude Opus 4.6 --- lib/config.py | 3 +++ lib/governor.py | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/config.py b/lib/config.py index 932cace..b0f75ef 100644 --- a/lib/config.py +++ b/lib/config.py @@ -137,6 +137,9 @@ def get_compute_config() -> dict[str, Any]: "per_task_timeout_minutes": 30, "cooldown_between_tasks": 60, "max_queue_depth": 20, + "rate_limits": { + "tasks_per_hour": 5, + }, } custom = settings.get("messaging", {}).get("compute", {}) defaults.update(custom) diff --git a/lib/governor.py b/lib/governor.py index dbbfbc1..54c787d 100644 --- a/lib/governor.py +++ b/lib/governor.py @@ -159,6 +159,18 @@ def check_task( priority_boost=boost, ) + # Check global queue depth + max_queue = compute.get("max_queue_depth", 20) + active_tasks = sum(s.active_tasks for s in self._state.values()) + if active_tasks >= max_queue: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Queue full: {active_tasks}/{max_queue}", + priority_boost=boost, + ) + return TaskCheckResult( allowed=True, trust_tier=tier_name, @@ -173,9 +185,26 @@ def check_and_record( effort: int = 0, ) -> TaskCheckResult: """Check and atomically record a task submission if allowed.""" - result = self.check_task(actor_id, estimated_tokens=estimated_tokens, effort=effort) - if result.allowed: - self.record_task_submission(actor_id) + with FileLock(self._budget_file): + self._load_state() + result = self.check_task(actor_id, estimated_tokens=estimated_tokens, effort=effort) + if result.allowed: + sender = self._get_sender(actor_id) + sender.tasks_this_hour.append(time.time()) + sender.tasks_today += 1 + sender.active_tasks += 1 + now = time.time() + hour_ago = now - 3600 + data = {"date": self._today, "senders": {}} + for s_id, state in self._state.items(): + state.tasks_this_hour = [t for t in state.tasks_this_hour if t > hour_ago] + data["senders"][s_id] = { + "tokens_today": state.tokens_today, + "tasks_this_hour": state.tasks_this_hour, + "tasks_today": state.tasks_today, + "active_tasks": state.active_tasks, + } + self._budget_file.write_text(json.dumps(data, indent=2)) return result def record_usage(self, actor_id: str, tokens: int) -> None: @@ -201,6 +230,7 @@ def record_task_completion(self, actor_id: str) -> None: def get_usage(self, actor_id: str) -> dict: """Get usage stats for an actor.""" + self._check_date_rollover() sender = self._get_sender(actor_id) return {"tokens_today": sender.tokens_today, "tasks_today": sender.tasks_today} From 72dfd68326d3df0c39760627552aa10ed9c399a2 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 18:13:52 +0100 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20consolidate=20relay=20to=20single?= =?UTF-8?q?=20lib/relay.py=20=E2=80=94=20fix=20auth=20leak,=20-h=20collisi?= =?UTF-8?q?on,=20empty=20secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create lib/relay.py as single canonical relay implementation - Fix /status endpoint: no longer leaks connected usernames (count only) - Fix arg parsing: --host flag instead of -h to avoid collision with --help - Fix startup: raises ValueError with clear message if RELAY_SECRET is empty - Delete lib/datacore-msg-relay.py and relay/datacore-msg-relay.py (duplicates) - Update relay/Dockerfile to use lib/ layout and python -m lib.relay - Update Procfile to use python -m lib.relay --host - Add tests/test_relay.py with 5 tests covering auth, startup, arg parsing - All 46 tests passing Co-Authored-By: Claude Opus 4.6 --- Procfile | 2 +- lib/datacore-msg-relay.py | 325 ------------------------------------ lib/relay.py | 157 +++++++++++++++++ relay/Dockerfile | 25 +-- relay/datacore-msg-relay.py | 325 ------------------------------------ tests/test_relay.py | 53 ++++++ 6 files changed, 216 insertions(+), 671 deletions(-) delete mode 100644 lib/datacore-msg-relay.py create mode 100644 lib/relay.py delete mode 100644 relay/datacore-msg-relay.py create mode 100644 tests/test_relay.py diff --git a/Procfile b/Procfile index 6ca1db2..b3ec439 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: python lib/datacore-msg-relay.py +web: python -m lib.relay --host diff --git a/lib/datacore-msg-relay.py b/lib/datacore-msg-relay.py deleted file mode 100644 index 1b44097..0000000 --- a/lib/datacore-msg-relay.py +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env python3 -""" -datacore-msg-relay - WebSocket relay server for Datacore messaging - -Simple shared-secret authentication for team messaging. - -Environment variables: - RELAY_SECRET - Shared secret for authentication (required) - PORT - Server port (default: 8080) - -Usage: - # Local development - RELAY_SECRET=mysecret python datacore-msg-relay.py - - # Production (fly.io) - fly secrets set RELAY_SECRET=$(openssl rand -hex 32) - fly deploy -""" - -import asyncio -import json -import os -import time -from dataclasses import dataclass, field -from typing import Optional - -from aiohttp import web, WSMsgType - -# === CONFIG === - -RELAY_SECRET = os.environ.get("RELAY_SECRET", "") -PORT = int(os.environ.get("PORT", 8080)) - - -# === DATA STRUCTURES === - -@dataclass -class User: - """Connected user.""" - username: str - ws: web.WebSocketResponse - connected_at: float = field(default_factory=time.time) - claude_whitelist: list = field(default_factory=list) # Users allowed to message this user's Claude - - -@dataclass -class RelayServer: - """Manages WebSocket connections and message routing.""" - users: dict[str, User] = field(default_factory=dict) - - def add_user(self, user: User): - """Add connected user.""" - # Disconnect existing connection if any - if user.username in self.users: - old = self.users[user.username] - asyncio.create_task(old.ws.close()) - self.users[user.username] = user - - def remove_user(self, username: str): - """Remove disconnected user.""" - self.users.pop(username, None) - - def get_user(self, username: str) -> Optional[User]: - """Get user by username.""" - return self.users.get(username) - - def list_users(self) -> list[str]: - """List connected usernames.""" - return list(self.users.keys()) - - def resolve_claude_target(self, from_user: str, to_user: str) -> tuple: - """ - Resolve @claude to the sender's personal Claude agent. - Returns (resolved_target, is_allowed, auto_reply_msg). - """ - if to_user == "claude": - # Route @claude to sender's personal Claude: @-claude - resolved = f"{from_user}-claude" - return (resolved, True, None) - - # Check if messaging someone else's Claude (e.g., @gregor-claude) - if to_user.endswith("-claude"): - owner = to_user.rsplit("-claude", 1)[0] - owner_user = self.users.get(owner) - - # Check if owner has a whitelist configured - if owner_user and owner_user.claude_whitelist: - if from_user not in owner_user.claude_whitelist: - # Not whitelisted - return auto-reply - return (to_user, False, - f"Auto-reply: @{owner}-claude is not accepting messages from @{from_user}. " - f"Please contact @{owner} directly.") - - return (to_user, True, None) - - async def route_message(self, from_user: str, to_user: str, message: dict, sender_ws=None): - """Route message to recipient if online.""" - # Resolve @claude and check permissions - resolved_target, is_allowed, auto_reply = self.resolve_claude_target(from_user, to_user) - - if not is_allowed and auto_reply and sender_ws: - # Send auto-reply back to sender - await sender_ws.send_json({ - "type": "message", - "from": resolved_target, - "text": auto_reply, - "priority": "normal", - "auto_reply": True - }) - return "auto_replied" - - recipient = self.users.get(resolved_target) - if recipient: - await recipient.ws.send_json({ - "type": "message", - "from": from_user, - **message - }) - return True - return False - - -relay = RelayServer() - - -# === HTTP HANDLERS === - -async def handle_status(request: web.Request) -> web.Response: - """Return relay status.""" - return web.json_response({ - "status": "ok", - "users_online": len(relay.users), - "users": relay.list_users() - }) - - -# === WEBSOCKET HANDLER === - -async def handle_websocket(request: web.Request) -> web.WebSocketResponse: - """Handle WebSocket connections.""" - ws = web.WebSocketResponse(heartbeat=30) - await ws.prepare(request) - - username = None - - try: - async for msg in ws: - if msg.type == WSMsgType.TEXT: - try: - data = json.loads(msg.data) - except json.JSONDecodeError: - await ws.send_json({"type": "error", "message": "Invalid JSON"}) - continue - - msg_type = data.get("type") - - # === AUTH === - if msg_type == "auth": - secret = data.get("secret", "") - claimed_username = data.get("username", "") - - # Verify shared secret - if not RELAY_SECRET: - await ws.send_json({ - "type": "auth_error", - "message": "Server not configured (no RELAY_SECRET)" - }) - continue - - if secret != RELAY_SECRET: - await ws.send_json({ - "type": "auth_error", - "message": "Invalid secret" - }) - continue - - if not claimed_username: - await ws.send_json({ - "type": "auth_error", - "message": "Username required" - }) - continue - - username = claimed_username - claude_whitelist = data.get("claude_whitelist", []) - - # Add to relay - relay.add_user(User( - username=username, - ws=ws, - claude_whitelist=claude_whitelist - )) - - await ws.send_json({ - "type": "auth_ok", - "username": username, - "online": relay.list_users() - }) - - # Broadcast presence - await broadcast_presence(username, "online") - - # === PRESENCE === - elif msg_type == "presence": - if not username: - await ws.send_json({"type": "error", "message": "Not authenticated"}) - continue - - await ws.send_json({ - "type": "presence", - "online": relay.list_users() - }) - - # === SEND MESSAGE === - elif msg_type == "send": - if not username: - await ws.send_json({"type": "error", "message": "Not authenticated"}) - continue - - to_user = data.get("to", "").lstrip("@") - text = data.get("text", "") - priority = data.get("priority", "normal") - msg_id = data.get("msg_id", "") - - if not to_user or not text: - await ws.send_json({ - "type": "error", - "message": "Missing 'to' or 'text'" - }) - continue - - # Resolve @claude to sender's personal Claude - resolved_target, _, _ = relay.resolve_claude_target(username, to_user) - - # Try to deliver - result = await relay.route_message( - from_user=username, - to_user=to_user, - message={ - "text": text, - "priority": priority, - "msg_id": msg_id, - "timestamp": time.time() - }, - sender_ws=ws - ) - - if result == "auto_replied": - await ws.send_json({ - "type": "send_ack", - "to": resolved_target, - "msg_id": msg_id, - "delivered": False, - "auto_replied": True - }) - else: - await ws.send_json({ - "type": "send_ack", - "to": resolved_target, - "msg_id": msg_id, - "delivered": bool(result), - "queued": not result - }) - - # === PING/PONG === - elif msg_type == "ping": - await ws.send_json({"type": "pong"}) - - elif msg.type == WSMsgType.ERROR: - print(f"WebSocket error: {ws.exception()}") - - finally: - if username: - relay.remove_user(username) - await broadcast_presence(username, "offline") - - return ws - - -async def broadcast_presence(username: str, status: str): - """Broadcast user presence change to all connected users.""" - message = { - "type": "presence_change", - "user": username, - "status": status, - "online": relay.list_users() - } - - for user in list(relay.users.values()): - if user.username != username: - try: - await user.ws.send_json(message) - except: - pass - - -# === APP === - -def create_app() -> web.Application: - """Create aiohttp application.""" - app = web.Application() - - app.router.add_get("/", handle_status) - app.router.add_get("/status", handle_status) - app.router.add_get("/ws", handle_websocket) - - return app - - -def main(): - """Run the relay server.""" - if not RELAY_SECRET: - print("WARNING: RELAY_SECRET not set!") - print("Set RELAY_SECRET environment variable for authentication.") - print("Example: RELAY_SECRET=mysecret python datacore-msg-relay.py") - print() - - app = create_app() - print(f"Starting relay server on port {PORT}") - print(f"Secret configured: {'yes' if RELAY_SECRET else 'NO'}") - web.run_app(app, port=PORT) - - -if __name__ == "__main__": - main() diff --git a/lib/relay.py b/lib/relay.py new file mode 100644 index 0000000..7e50760 --- /dev/null +++ b/lib/relay.py @@ -0,0 +1,157 @@ +"""WebSocket relay server — single consolidated implementation. + +Replaces the three duplicate relay files (lib/, relay/, embedded in GUI). +Fixes: auth leak on /status, -h flag collision, empty secret startup. +""" + +import argparse +import json +import logging +import os +import time +from dataclasses import dataclass, field + +from aiohttp import web, WSMsgType + +logger = logging.getLogger(__name__) + + +@dataclass +class User: + username: str + ws: web.WebSocketResponse + connected_at: float = field(default_factory=time.time) + status: str = "online" + + +class RelayServer: + def __init__(self, secret: str, claude_whitelist: dict | None = None): + self.secret = secret + self.users: dict[str, User] = {} + self.claude_whitelist = claude_whitelist or {} + + def add_user(self, username: str, ws: web.WebSocketResponse) -> None: + self.users[username] = User(username=username, ws=ws) + + def remove_user(self, username: str) -> None: + self.users.pop(username, None) + + def list_users(self) -> list[str]: + return list(self.users.keys()) + + def connected_count(self) -> int: + return len(self.users) + + async def route_message(self, msg: dict, sender: str) -> bool: + target = msg.get("to", "") + if not target or target not in self.users: + return False + try: + await self.users[target].ws.send_json(msg) + return True + except Exception: + logger.warning("Failed to deliver message to %s", target) + return False + + async def broadcast_presence(self, username: str, status: str) -> None: + event = {"type": "presence", "username": username, "status": status} + for user in self.users.values(): + if user.username != username: + try: + await user.ws.send_json(event) + except Exception: + pass + + +def create_relay_app(relay_secret: str) -> web.Application: + """Create the aiohttp relay application. Raises ValueError if relay_secret is empty.""" + if not relay_secret: + raise ValueError( + "RELAY_SECRET must be set. " + "Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'" + ) + + relay = RelayServer(secret=relay_secret) + app = web.Application() + app["relay"] = relay + + async def handle_status(request: web.Request) -> web.Response: + """Status endpoint — shows connected count only, never usernames.""" + return web.json_response({"status": "ok", "connected": relay.connected_count()}) + + async def handle_ws(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + username = None + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + data = json.loads(msg.data) + except json.JSONDecodeError: + continue + msg_type = data.get("type") + if msg_type == "auth": + if data.get("secret") != relay.secret: + await ws.send_json({"type": "error", "message": "Invalid secret"}) + await ws.close() + return ws + username = data.get("username", "") + if not username: + await ws.send_json({"type": "error", "message": "Username required"}) + await ws.close() + return ws + relay.add_user(username, ws) + await ws.send_json({"type": "auth_ok"}) + await relay.broadcast_presence(username, "online") + logger.info("User connected: %s", username) + elif msg_type == "send" and username: + payload = { + "type": "message", "from": username, "to": data.get("to"), + "content": data.get("content", ""), "id": data.get("id", ""), + "thread": data.get("thread"), "reply_to": data.get("reply_to"), + "timestamp": data.get("timestamp", time.strftime("%Y-%m-%dT%H:%M:%S")), + } + delivered = await relay.route_message(payload, username) + if not delivered: + await ws.send_json({"type": "error", "message": f"User {data.get('to')} not online"}) + elif msg_type == "status_change" and username: + new_status = data.get("status", "online") + if username in relay.users: + relay.users[username].status = new_status + await relay.broadcast_presence(username, new_status) + elif msg_type == "ping": + await ws.send_json({"type": "pong"}) + elif msg.type == WSMsgType.ERROR: + logger.error("WS error: %s", ws.exception()) + finally: + if username: + relay.remove_user(username) + await relay.broadcast_presence(username, "offline") + logger.info("User disconnected: %s", username) + return ws + + app.router.add_get("/status", handle_status) + app.router.add_get("/ws", handle_ws) + return app + + +def parse_relay_args(args: list[str] | None = None) -> argparse.Namespace: + """Parse relay CLI arguments. Uses --host (not -h) for hosting.""" + parser = argparse.ArgumentParser(description="Datacore messaging relay") + parser.add_argument("--host", action="store_true", help="Host relay server") + parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)") + parser.add_argument("--bind", default="0.0.0.0", help="Bind address") + return parser.parse_args(args) + + +def run_relay() -> None: + """Entry point for standalone relay server.""" + args = parse_relay_args() + secret = os.environ.get("RELAY_SECRET", "") + app = create_relay_app(relay_secret=secret) + web.run_app(app, host=args.bind, port=args.port) + + +if __name__ == "__main__": + run_relay() diff --git a/relay/Dockerfile b/relay/Dockerfile index 65e37c6..7f54ee5 100644 --- a/relay/Dockerfile +++ b/relay/Dockerfile @@ -1,21 +1,6 @@ -FROM python:3.12-slim - +FROM python:3.11-slim WORKDIR /app - -# Install dependencies -RUN pip install --no-cache-dir aiohttp - -# Copy relay server -COPY datacore-msg-relay.py . - -# Environment -ENV PORT=8080 -ENV RELAY_SECRET="" - -EXPOSE 8080 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/status')" || exit 1 - -CMD ["python", "datacore-msg-relay.py"] +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY lib/ lib/ +CMD ["python", "-m", "lib.relay", "--host"] diff --git a/relay/datacore-msg-relay.py b/relay/datacore-msg-relay.py deleted file mode 100644 index 1b44097..0000000 --- a/relay/datacore-msg-relay.py +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env python3 -""" -datacore-msg-relay - WebSocket relay server for Datacore messaging - -Simple shared-secret authentication for team messaging. - -Environment variables: - RELAY_SECRET - Shared secret for authentication (required) - PORT - Server port (default: 8080) - -Usage: - # Local development - RELAY_SECRET=mysecret python datacore-msg-relay.py - - # Production (fly.io) - fly secrets set RELAY_SECRET=$(openssl rand -hex 32) - fly deploy -""" - -import asyncio -import json -import os -import time -from dataclasses import dataclass, field -from typing import Optional - -from aiohttp import web, WSMsgType - -# === CONFIG === - -RELAY_SECRET = os.environ.get("RELAY_SECRET", "") -PORT = int(os.environ.get("PORT", 8080)) - - -# === DATA STRUCTURES === - -@dataclass -class User: - """Connected user.""" - username: str - ws: web.WebSocketResponse - connected_at: float = field(default_factory=time.time) - claude_whitelist: list = field(default_factory=list) # Users allowed to message this user's Claude - - -@dataclass -class RelayServer: - """Manages WebSocket connections and message routing.""" - users: dict[str, User] = field(default_factory=dict) - - def add_user(self, user: User): - """Add connected user.""" - # Disconnect existing connection if any - if user.username in self.users: - old = self.users[user.username] - asyncio.create_task(old.ws.close()) - self.users[user.username] = user - - def remove_user(self, username: str): - """Remove disconnected user.""" - self.users.pop(username, None) - - def get_user(self, username: str) -> Optional[User]: - """Get user by username.""" - return self.users.get(username) - - def list_users(self) -> list[str]: - """List connected usernames.""" - return list(self.users.keys()) - - def resolve_claude_target(self, from_user: str, to_user: str) -> tuple: - """ - Resolve @claude to the sender's personal Claude agent. - Returns (resolved_target, is_allowed, auto_reply_msg). - """ - if to_user == "claude": - # Route @claude to sender's personal Claude: @-claude - resolved = f"{from_user}-claude" - return (resolved, True, None) - - # Check if messaging someone else's Claude (e.g., @gregor-claude) - if to_user.endswith("-claude"): - owner = to_user.rsplit("-claude", 1)[0] - owner_user = self.users.get(owner) - - # Check if owner has a whitelist configured - if owner_user and owner_user.claude_whitelist: - if from_user not in owner_user.claude_whitelist: - # Not whitelisted - return auto-reply - return (to_user, False, - f"Auto-reply: @{owner}-claude is not accepting messages from @{from_user}. " - f"Please contact @{owner} directly.") - - return (to_user, True, None) - - async def route_message(self, from_user: str, to_user: str, message: dict, sender_ws=None): - """Route message to recipient if online.""" - # Resolve @claude and check permissions - resolved_target, is_allowed, auto_reply = self.resolve_claude_target(from_user, to_user) - - if not is_allowed and auto_reply and sender_ws: - # Send auto-reply back to sender - await sender_ws.send_json({ - "type": "message", - "from": resolved_target, - "text": auto_reply, - "priority": "normal", - "auto_reply": True - }) - return "auto_replied" - - recipient = self.users.get(resolved_target) - if recipient: - await recipient.ws.send_json({ - "type": "message", - "from": from_user, - **message - }) - return True - return False - - -relay = RelayServer() - - -# === HTTP HANDLERS === - -async def handle_status(request: web.Request) -> web.Response: - """Return relay status.""" - return web.json_response({ - "status": "ok", - "users_online": len(relay.users), - "users": relay.list_users() - }) - - -# === WEBSOCKET HANDLER === - -async def handle_websocket(request: web.Request) -> web.WebSocketResponse: - """Handle WebSocket connections.""" - ws = web.WebSocketResponse(heartbeat=30) - await ws.prepare(request) - - username = None - - try: - async for msg in ws: - if msg.type == WSMsgType.TEXT: - try: - data = json.loads(msg.data) - except json.JSONDecodeError: - await ws.send_json({"type": "error", "message": "Invalid JSON"}) - continue - - msg_type = data.get("type") - - # === AUTH === - if msg_type == "auth": - secret = data.get("secret", "") - claimed_username = data.get("username", "") - - # Verify shared secret - if not RELAY_SECRET: - await ws.send_json({ - "type": "auth_error", - "message": "Server not configured (no RELAY_SECRET)" - }) - continue - - if secret != RELAY_SECRET: - await ws.send_json({ - "type": "auth_error", - "message": "Invalid secret" - }) - continue - - if not claimed_username: - await ws.send_json({ - "type": "auth_error", - "message": "Username required" - }) - continue - - username = claimed_username - claude_whitelist = data.get("claude_whitelist", []) - - # Add to relay - relay.add_user(User( - username=username, - ws=ws, - claude_whitelist=claude_whitelist - )) - - await ws.send_json({ - "type": "auth_ok", - "username": username, - "online": relay.list_users() - }) - - # Broadcast presence - await broadcast_presence(username, "online") - - # === PRESENCE === - elif msg_type == "presence": - if not username: - await ws.send_json({"type": "error", "message": "Not authenticated"}) - continue - - await ws.send_json({ - "type": "presence", - "online": relay.list_users() - }) - - # === SEND MESSAGE === - elif msg_type == "send": - if not username: - await ws.send_json({"type": "error", "message": "Not authenticated"}) - continue - - to_user = data.get("to", "").lstrip("@") - text = data.get("text", "") - priority = data.get("priority", "normal") - msg_id = data.get("msg_id", "") - - if not to_user or not text: - await ws.send_json({ - "type": "error", - "message": "Missing 'to' or 'text'" - }) - continue - - # Resolve @claude to sender's personal Claude - resolved_target, _, _ = relay.resolve_claude_target(username, to_user) - - # Try to deliver - result = await relay.route_message( - from_user=username, - to_user=to_user, - message={ - "text": text, - "priority": priority, - "msg_id": msg_id, - "timestamp": time.time() - }, - sender_ws=ws - ) - - if result == "auto_replied": - await ws.send_json({ - "type": "send_ack", - "to": resolved_target, - "msg_id": msg_id, - "delivered": False, - "auto_replied": True - }) - else: - await ws.send_json({ - "type": "send_ack", - "to": resolved_target, - "msg_id": msg_id, - "delivered": bool(result), - "queued": not result - }) - - # === PING/PONG === - elif msg_type == "ping": - await ws.send_json({"type": "pong"}) - - elif msg.type == WSMsgType.ERROR: - print(f"WebSocket error: {ws.exception()}") - - finally: - if username: - relay.remove_user(username) - await broadcast_presence(username, "offline") - - return ws - - -async def broadcast_presence(username: str, status: str): - """Broadcast user presence change to all connected users.""" - message = { - "type": "presence_change", - "user": username, - "status": status, - "online": relay.list_users() - } - - for user in list(relay.users.values()): - if user.username != username: - try: - await user.ws.send_json(message) - except: - pass - - -# === APP === - -def create_app() -> web.Application: - """Create aiohttp application.""" - app = web.Application() - - app.router.add_get("/", handle_status) - app.router.add_get("/status", handle_status) - app.router.add_get("/ws", handle_websocket) - - return app - - -def main(): - """Run the relay server.""" - if not RELAY_SECRET: - print("WARNING: RELAY_SECRET not set!") - print("Set RELAY_SECRET environment variable for authentication.") - print("Example: RELAY_SECRET=mysecret python datacore-msg-relay.py") - print() - - app = create_app() - print(f"Starting relay server on port {PORT}") - print(f"Secret configured: {'yes' if RELAY_SECRET else 'NO'}") - web.run_app(app, port=PORT) - - -if __name__ == "__main__": - main() diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..e5d68db --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,53 @@ +import json +import pytest +from aiohttp import web +from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop + + +@pytest.fixture +def relay_app(): + from lib.relay import create_relay_app + return create_relay_app(relay_secret="test-secret-123") + + +class TestRelayAuth: + @pytest.mark.asyncio + async def test_status_requires_no_user_list(self, relay_app, aiohttp_client): + """Status endpoint must NOT leak connected usernames.""" + client = await aiohttp_client(relay_app) + resp = await client.get("/status") + data = await resp.json() + assert resp.status == 200 + assert "users" not in data + assert "users_online" not in data + assert data["status"] == "ok" + + @pytest.mark.asyncio + async def test_status_shows_count_only(self, relay_app, aiohttp_client): + client = await aiohttp_client(relay_app) + resp = await client.get("/status") + data = await resp.json() + assert "connected" in data + assert isinstance(data["connected"], int) + + +class TestRelayStartup: + def test_relay_refuses_empty_secret(self): + from lib.relay import create_relay_app + with pytest.raises(ValueError, match="RELAY_SECRET"): + create_relay_app(relay_secret="") + + def test_relay_accepts_valid_secret(self): + from lib.relay import create_relay_app + app = create_relay_app(relay_secret="valid-secret") + assert app is not None + + +class TestArgParsing: + def test_host_flag_not_h(self): + """Verify -h is NOT used for hosting (it's --help).""" + from lib.relay import parse_relay_args + args = parse_relay_args(["--host"]) + assert args.host is True + with pytest.raises(SystemExit): + parse_relay_args(["-h"]) From 294e7974a66523d11b373bda5dd7947058dd47de Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 18:16:08 +0100 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20rewire=20all=20hooks=20to=20u?= =?UTF-8?q?se=20lib=20modules=20=E2=80=94=20remove=20duplicated=20code,=20?= =?UTF-8?q?use=20org-workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- hooks/inbox-watcher.py | 253 +++----------------------- hooks/mark-message.py | 118 ++++-------- hooks/send-reply.py | 399 ++++------------------------------------- hooks/task-queue.py | 236 +++++------------------- tests/test_hooks.py | 84 +++++++++ 5 files changed, 211 insertions(+), 879 deletions(-) create mode 100644 tests/test_hooks.py diff --git a/hooks/inbox-watcher.py b/hooks/inbox-watcher.py index 8a9797e..3895526 100755 --- a/hooks/inbox-watcher.py +++ b/hooks/inbox-watcher.py @@ -1,246 +1,43 @@ #!/usr/bin/env python3 -""" -Claude Code hook: Watch tex-claude inbox for new messages. - -This hook runs on UserPromptSubmit and checks for unread messages -in the user's Claude inbox. If found, it injects them into context. - -Install: - Add to ~/.claude/settings.json or .claude/settings.local.json: +"""Inbox watcher hook — checks for queued tasks and claims the next one. - { - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "/path/to/datacore-messaging/hooks/inbox-watcher.py" - } - ] - } - ] - } - } +Claude Code hook: runs on prompt_submit to inject task context. +Uses lib/ modules instead of raw file parsing. """ -import os import sys -import re from pathlib import Path -from datetime import datetime -# Config -DATACORE_ROOT = Path(os.environ.get("DATACORE_ROOT", Path.home() / "Data")) +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) -def get_username(): - """Get username from settings or environment.""" - # Try module settings first - module_settings = Path(__file__).parent.parent / "settings.local.yaml" - if module_settings.exists(): - try: - import yaml - conf = yaml.safe_load(module_settings.read_text()) or {} - name = conf.get("identity", {}).get("name") - if name: - return name - except: - pass +from lib.config import get_username, get_default_space, datacore_root +from lib.agent_inbox import AgentInbox - # Fallback to system user - return os.environ.get("USER", "unknown") -def get_claude_inbox(): - """Get path to user's Claude inbox.""" +def main() -> None: username = get_username() - claude_name = f"{username}-claude" - - # Search all spaces for the inbox - for inbox in DATACORE_ROOT.glob(f"*/org/inboxes/{claude_name}.org"): - return inbox - - return None - -def parse_messages(content): - """Parse MESSAGE blocks from org content.""" - messages = [] - - for block in content.split("\n* MESSAGE ")[1:]: - lines = block.split("\n") - header = lines[0] if lines else "" - - # Check if unread - if ":unread:" not in header: - continue - - # Extract timestamp - time_str = "" - if "[" in header and "]" in header: - ts = header[header.find("[")+1:header.find("]")] - time_str = ts - - # Parse properties - props = {} - text_lines = [] - in_props = False - - for line in lines[1:]: - if ":PROPERTIES:" in line: - in_props = True - elif ":END:" in line: - in_props = False - elif in_props and ": " in line: - line = line.strip() - if line.startswith(":") and ": " in line[1:]: - key_val = line[1:].split(": ", 1) - if len(key_val) == 2: - props[key_val[0].lower()] = key_val[1] - elif not in_props and line.strip(): - text_lines.append(line) - - msg_id = props.get("id", "") - if msg_id: - messages.append({ - "id": msg_id, - "from": props.get("from", "?"), - "text": "\n".join(text_lines).strip(), - "time": time_str, - "priority": props.get("priority", "normal"), - }) - - return messages - -def mark_messages_as_working(inbox_path, message_ids): - """Mark messages as 'working' - remove :unread:, add TASK_STATUS and STARTED_AT.""" - if not inbox_path.exists(): + space = get_default_space() + space_root = datacore_root() / space + inbox = AgentInbox(space_root, f"{username}-claude") + + working = inbox.find_by_state("WORKING") + if working: + task = working[0] + print(f"[messaging] Task in progress: {task.heading}") return - content = inbox_path.read_text() - modified = False - now = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") - - for msg_id in message_ids: - # Find the message block and update it - lines = content.split('\n') - new_lines = [] - i = 0 - - while i < len(lines): - line = lines[i] - - # Check if this MESSAGE block contains our msg_id - if line.startswith('* MESSAGE [') and ':unread:' in line: - block_end = min(i + 15, len(lines)) - block_has_id = any(msg_id in lines[j] for j in range(i, block_end)) - - if block_has_id: - # Remove :unread: tag - header = line.replace(' :unread:', '') - new_lines.append(header) - i += 1 - - # Process properties block - while i < len(lines): - prop_line = lines[i] - new_lines.append(prop_line) - - if ':END:' in prop_line: - # Insert task status properties before :END: - new_lines.pop() # Remove :END: temporarily - new_lines.append(f":TASK_STATUS: working") - new_lines.append(f":STARTED_AT: {now}") - new_lines.append(prop_line) # Put :END: back - i += 1 - break - i += 1 - - modified = True - continue - - new_lines.append(line) - i += 1 - - if modified: - content = '\n'.join(new_lines) - - if modified: - inbox_path.write_text(content) - - -def get_working_task_count(inbox_path): - """Count tasks currently being worked on.""" - if not inbox_path or not inbox_path.exists(): - return 0 - - try: - content = inbox_path.read_text() - return content.count(":TASK_STATUS: working") - except: - return 0 - - -def main(): - inbox = get_claude_inbox() - - if not inbox or not inbox.exists(): - # No inbox, nothing to inject - sys.exit(0) - - try: - content = inbox.read_text() - except: - sys.exit(0) - - messages = parse_messages(content) - - if not messages: - sys.exit(0) - - # Check if there's already a task being worked on - working_count = get_working_task_count(inbox) - if working_count > 0: - # Show queue status instead of loading more tasks - username = get_username() - print(f"\n⏳ @{username}-claude has {working_count} task(s) in progress.") - print(f"📋 {len(messages)} task(s) queued.") - print() - print("Complete current task(s) first using:") - print(" hooks/send-reply.py --complete ") - print() - print("Or view queue with: /tasks in GUI") - sys.exit(0) - - # Sort by priority (high first) - messages.sort(key=lambda m: (0 if m["priority"] == "high" else 1, m["id"])) - - # Only process one task at a time - task = messages[0] - remaining = len(messages) - 1 - - # Output the task to inject into context - username = get_username() - print(f"\n📬 Task for @{username}-claude:\n") - - priority_marker = " [!]" if task["priority"] == "high" else "" - print(f"From @{task['from']} ({task['time']}){priority_marker}:") - print(f" {task['text']}") - print(f" [msg-id: {task['id']}]") - print() - - if remaining > 0: - print(f"📋 {remaining} more task(s) queued.") - print() - - print("---") - print("To reply: use hooks/send-reply.py ") - print(f"To complete: use hooks/send-reply.py --complete {task['id']} {task['from']} ") - print() - print("Task is now marked as 'working'.") + queued = inbox.find_by_state("QUEUED") + if not queued: + return - # Mark only this one task as working - mark_messages_as_working(inbox, [task["id"]]) + task = queued[0] + inbox.claim(task) + print(f"[messaging] Claimed task: {task.heading}") + print(f" From: {task.properties.get('FROM', 'unknown')}") + print(f" Tier: {task.properties.get('TRUST_TIER', 'unknown')}") - sys.exit(0) if __name__ == "__main__": main() diff --git a/hooks/mark-message.py b/hooks/mark-message.py index 3a780ab..5357c39 100755 --- a/hooks/mark-message.py +++ b/hooks/mark-message.py @@ -1,96 +1,38 @@ #!/usr/bin/env python3 -""" -Helper script for Claude to mark messages as todo/done/read. +"""Mark message hook — mark messages as read or archived.""" -Usage: - ./mark-message.py - - status: todo, done, read (clears tags) - -Example: - ./mark-message.py 151230 todo - ./mark-message.py msg-20251212-151230-tex done -""" - -import os import sys -import re +import argparse from pathlib import Path -DATACORE_ROOT = Path(os.environ.get("DATACORE_ROOT", Path.home() / "Data")) -MODULE_DIR = Path(__file__).parent.parent - - -def get_username(): - """Get username from settings.""" - module_settings = MODULE_DIR / "settings.local.yaml" - if module_settings.exists(): - try: - import yaml - conf = yaml.safe_load(module_settings.read_text()) or {} - name = conf.get("identity", {}).get("name") - if name: - return name - except: - pass - return os.environ.get("USER", "unknown") - - -def mark_message(msg_id_part: str, action: str) -> bool: - """Mark a message with the given status.""" - username = get_username() - - for inbox in DATACORE_ROOT.glob(f"*/org/inboxes/{username}*.org"): - try: - content = inbox.read_text() - if msg_id_part in content: - pattern = rf'(\* MESSAGE \[[^\]]+\])([^\n]*)(.*?:ID: [^\n]*{re.escape(msg_id_part)}[^\n]*)' - - def replace_tags(match): - header = match.group(1) - tags = match.group(2) - rest = match.group(3) - tags = re.sub(r':(?:unread|todo|done):', '', tags).strip() - - if action == "todo": - return f"{header} :todo:{rest}" - elif action == "done": - return f"{header} :done:{rest}" - else: # read/clear - return f"{header}{rest}" - - new_content, count = re.subn(pattern, replace_tags, content, flags=re.DOTALL) - if count > 0: - inbox.write_text(new_content) - return True - except Exception as e: - print(f"Error processing {inbox}: {e}", file=sys.stderr) - - return False - - -def main(): - if len(sys.argv) < 3: - print("Usage: mark-message.py ", file=sys.stderr) - print(" status: todo, done, read", file=sys.stderr) - sys.exit(1) - - msg_id = sys.argv[1] - action = sys.argv[2].lower() - - if action not in ("todo", "done", "read", "clear"): - print(f"Invalid status: {action}", file=sys.stderr) - print(" Use: todo, done, or read", file=sys.stderr) - sys.exit(1) - - if action == "clear": - action = "read" - - if mark_message(msg_id, action): - print(f"✓ Marked as {action}: {msg_id}") - else: - print(f"✗ Message not found: {msg_id}", file=sys.stderr) - sys.exit(1) +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import get_default_space, datacore_root +from lib.message_store import MessageStore + + +def main() -> None: + parser = argparse.ArgumentParser(description="Mark message state") + parser.add_argument("msg_id", help="Message ID") + parser.add_argument("action", choices=["read", "archive"], help="Action") + args = parser.parse_args() + + space = get_default_space() + space_root = datacore_root() / space + store = MessageStore(space_root) + node = store.find_by_id(args.msg_id) + if not node: + print(f"[messaging] Message not found: {args.msg_id}") + return + + if args.action == "read": + store.mark_read(node) + print(f"[messaging] Marked read: {args.msg_id}") + elif args.action == "archive": + store.archive(node) + print(f"[messaging] Archived: {args.msg_id}") if __name__ == "__main__": diff --git a/hooks/send-reply.py b/hooks/send-reply.py index c2e4dbe..39cba54 100755 --- a/hooks/send-reply.py +++ b/hooks/send-reply.py @@ -1,380 +1,45 @@ #!/usr/bin/env python3 -""" -Helper script for Claude to send replies via the messaging system. +"""Send reply hook — creates outgoing messages and completes agent tasks.""" -Usage: - ./send-reply.py - ./send-reply.py --reply-to - ./send-reply.py --complete - ./send-reply.py --route - -Routing destinations: - --route github:123 Post to GitHub issue #123 - --route file:path/to.md Append to file - --route @user CC to another user - -Example: - ./send-reply.py tex "I've completed the task you requested!" - ./send-reply.py --reply-to msg-20251212-143000-tex tex "Here's the follow-up" - ./send-reply.py --complete msg-20251212-143000-gregor gregor "Task complete! See results." - ./send-reply.py --route github:42 gregor "Fixed in PR #50" - ./send-reply.py --route file:research/analysis.md gregor "Research complete" -""" - -import os import sys -import json -import asyncio +import argparse from pathlib import Path -from datetime import datetime - -# Try websockets for relay -try: - import websockets - HAS_WEBSOCKETS = True -except ImportError: - HAS_WEBSOCKETS = False - -DATACORE_ROOT = Path(os.environ.get("DATACORE_ROOT", Path.home() / "Data")) -MODULE_DIR = Path(__file__).parent.parent - - -def get_settings(): - """Load settings.""" - module_settings = MODULE_DIR / "settings.local.yaml" - if module_settings.exists(): - try: - import yaml - return yaml.safe_load(module_settings.read_text()) or {} - except: - pass - return {} - - -def get_username(): - """Get Claude's username (user-claude).""" - conf = get_settings() - user = conf.get("identity", {}).get("name", os.environ.get("USER", "unknown")) - return f"{user}-claude" - - -def get_default_space(): - """Get default space.""" - conf = get_settings() - space = conf.get("messaging", {}).get("default_space") - if space: - return space - for p in sorted(DATACORE_ROOT.glob("[1-9]-*")): - if p.is_dir(): - return p.name - return "1-team" - - -def get_thread_for_message(msg_id): - """Find thread ID for a message.""" - for inbox in DATACORE_ROOT.glob("*/org/inboxes/*.org"): - try: - content = inbox.read_text() - if msg_id not in content: - continue - for block in content.split("\n* MESSAGE ")[1:]: - if msg_id in block: - for line in block.split("\n"): - if ":THREAD:" in line: - return line.split(":THREAD:")[1].strip() - return None - except: - pass - return None - - -def mark_task_done(msg_id): - """Mark a task message as done with completion timestamp.""" - now = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") - - for inbox in DATACORE_ROOT.glob("*/org/inboxes/*.org"): - try: - content = inbox.read_text() - if msg_id not in content: - continue - - lines = content.split('\n') - new_lines = [] - modified = False - i = 0 - while i < len(lines): - line = lines[i] +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) - # Check if this MESSAGE block contains our msg_id - if line.startswith('* MESSAGE ['): - block_end = min(i + 20, len(lines)) - block_has_id = any(msg_id in lines[j] for j in range(i, block_end)) +from lib.config import get_username, get_default_space, datacore_root +from lib.message_store import MessageStore +from lib.agent_inbox import AgentInbox - if block_has_id: - # Add :done: tag if not present - if ':done:' not in line: - line = line.rstrip() + ' :done:' - new_lines.append(line) - i += 1 - # Process properties block - while i < len(lines): - prop_line = lines[i] +def main() -> None: + parser = argparse.ArgumentParser(description="Send a reply") + parser.add_argument("to", help="Recipient actor ID") + parser.add_argument("content", help="Message content") + parser.add_argument("--reply-to", help="Message ID to reply to") + parser.add_argument("--complete-task", help="Task ID to mark complete") + parser.add_argument("--tokens", type=int, default=0, help="Tokens used") + args = parser.parse_args() - # Update TASK_STATUS if present - if ':TASK_STATUS:' in prop_line: - new_lines.append(':TASK_STATUS: done') - i += 1 - continue - - if ':END:' in prop_line: - # Add COMPLETED_AT before :END: - new_lines.append(f":COMPLETED_AT: {now}") - new_lines.append(prop_line) - i += 1 - break - - new_lines.append(prop_line) - i += 1 - - modified = True - continue - - new_lines.append(line) - i += 1 - - if modified: - inbox.write_text('\n'.join(new_lines)) - return True - - except Exception as e: - pass - - return False - - -def write_to_inbox(to_user, text, reply_to=None): - """Write message to local inbox.""" - space = get_default_space() - inbox_dir = DATACORE_ROOT / space / "org/inboxes" - inbox_dir.mkdir(parents=True, exist_ok=True) - inbox = inbox_dir / f"{to_user}.org" - - now = datetime.now() username = get_username() - msg_id = f"msg-{now.strftime('%Y%m%d-%H%M%S')}-{username}" - timestamp = now.strftime("[%Y-%m-%d %a %H:%M]") - - # Build properties - props = [ - f":ID: {msg_id}", - f":FROM: {username}", - f":TO: {to_user}", - ":PRIORITY: normal" - ] - - # Handle threading - thread_id = None - if reply_to: - thread_id = get_thread_for_message(reply_to) - if not thread_id: - thread_id = f"thread-{reply_to}" - props.append(f":THREAD: {thread_id}") - props.append(f":REPLY_TO: {reply_to}") - - props_str = "\n".join(props) - entry = f""" -* MESSAGE {timestamp} :unread: -:PROPERTIES: -{props_str} -:END: -{text} -""" - - with open(inbox, "a") as f: - f.write(entry) - - return msg_id, thread_id - - -def route_to_github(issue_num, text, username): - """Post comment to GitHub issue.""" - import subprocess - - try: - # Use gh CLI to post comment - result = subprocess.run( - ["gh", "issue", "comment", str(issue_num), "--body", text], - capture_output=True, - text=True - ) - if result.returncode == 0: - print(f"✓ Posted to GitHub issue #{issue_num}") - return True - else: - print(f"⚠ GitHub error: {result.stderr}") - return False - except FileNotFoundError: - print("⚠ GitHub CLI (gh) not found. Install with: brew install gh") - return False - except Exception as e: - print(f"⚠ GitHub routing failed: {e}") - return False - - -def route_to_file(filepath, text, username): - """Append message to file.""" - try: - # Resolve path relative to DATACORE_ROOT - if not filepath.startswith("/"): - full_path = DATACORE_ROOT / get_default_space() / filepath - else: - full_path = Path(filepath) - - full_path.parent.mkdir(parents=True, exist_ok=True) - - now = datetime.now().strftime("%Y-%m-%d %H:%M") - entry = f"\n\n## {username} ({now})\n\n{text}\n" - - with open(full_path, "a") as f: - f.write(entry) - - print(f"✓ Appended to {full_path}") - return True - except Exception as e: - print(f"⚠ File routing failed: {e}") - return False - - -def route_to_user(cc_user, text, reply_to=None): - """CC message to another user.""" - msg_id, thread_id = write_to_inbox(cc_user, text, reply_to=reply_to) - print(f"✓ CC'd to @{cc_user} (id: {msg_id})") - return msg_id, thread_id - - -async def send_via_relay(to_user, text, msg_id, thread_id=None, reply_to=None): - """Send via WebSocket relay.""" - conf = get_settings() - relay_conf = conf.get("messaging", {}).get("relay", {}) - url = relay_conf.get("url") - secret = relay_conf.get("secret") - - if not url or not secret: - return False - - try: - async with websockets.connect(url) as ws: - # Auth - await ws.send(json.dumps({ - "type": "auth", - "secret": secret, - "username": get_username() - })) - response = json.loads(await ws.recv()) - - if response.get("type") != "auth_ok": - return False - - # Send - msg = { - "type": "send", - "to": to_user, - "text": text, - "msg_id": msg_id, - "priority": "normal" - } - if thread_id: - msg["thread"] = thread_id - if reply_to: - msg["reply_to"] = reply_to - - await ws.send(json.dumps(msg)) - response = json.loads(await ws.recv()) - return response.get("delivered", False) - - except Exception as e: - print(f"Relay error: {e}", file=sys.stderr) - return False - - -def main(): - args = sys.argv[1:] - reply_to = None - complete_id = None - route_dest = None - - # Parse --reply-to flag - if "--reply-to" in args: - idx = args.index("--reply-to") - if idx + 1 < len(args): - reply_to = args[idx + 1] - args = args[:idx] + args[idx + 2:] - - # Parse --complete flag - if "--complete" in args: - idx = args.index("--complete") - if idx + 1 < len(args): - complete_id = args[idx + 1] - # --complete implies --reply-to for threading - if not reply_to: - reply_to = complete_id - args = args[:idx] + args[idx + 2:] - - # Parse --route flag - if "--route" in args: - idx = args.index("--route") - if idx + 1 < len(args): - route_dest = args[idx + 1] - args = args[:idx] + args[idx + 2:] - - if len(args) < 2: - print("Usage: send-reply.py [--reply-to ] [--complete ] [--route ] ", file=sys.stderr) - print("\nRouting destinations:") - print(" github:123 Post to GitHub issue #123") - print(" file:path.md Append to file") - print(" @user CC to another user") - sys.exit(1) - - to_user = args[0] - text = " ".join(args[1:]) - username = get_username() - - # Mark original task as done if --complete specified - if complete_id: - if mark_task_done(complete_id): - print(f"✓ Task {complete_id} marked as done") - else: - print(f"⚠ Could not find task {complete_id} to mark done") - - # Handle routing - if route_dest: - if route_dest.startswith("github:"): - issue_num = route_dest.split(":")[1] - route_to_github(issue_num, text, username) - elif route_dest.startswith("file:"): - filepath = route_dest.split(":", 1)[1] - route_to_file(filepath, text, username) - elif route_dest.startswith("@"): - cc_user = route_dest[1:] - route_to_user(cc_user, text, reply_to=reply_to) - - # Write to local inbox (primary recipient) - msg_id, thread_id = write_to_inbox(to_user, text, reply_to=reply_to) - print(f"Message saved to inbox (id: {msg_id})") - if thread_id: - print(f"Thread: {thread_id}") - - # Try relay - if HAS_WEBSOCKETS: - delivered = asyncio.run(send_via_relay(to_user, text, msg_id, thread_id, reply_to)) - if delivered: - print(f"Delivered via relay to @{to_user}") - else: - print(f"Queued for @{to_user} (not online)") - else: - print("Relay unavailable (no websockets)") + space = get_default_space() + space_root = datacore_root() / space + + store = MessageStore(space_root) + msg = store.create_message( + from_actor=username, to_actor=args.to, + content=args.content, reply_to=args.reply_to, + ) + print(f"[messaging] Sent: {msg.properties['ID']}") + + if args.complete_task: + inbox = AgentInbox(space_root, f"{username}-claude") + node = inbox.workspace.find_by_id(args.complete_task) + if node and node.todo == "WORKING": + inbox.complete(node, tokens_used=args.tokens) + print(f"[messaging] Completed task: {args.complete_task}") if __name__ == "__main__": diff --git a/hooks/task-queue.py b/hooks/task-queue.py index aae761a..636c956 100755 --- a/hooks/task-queue.py +++ b/hooks/task-queue.py @@ -1,209 +1,53 @@ #!/usr/bin/env python3 -""" -Task queue manager for Claude Code. +"""Task queue hook — query and manage agent task queue.""" -Ensures Claude only processes one task at a time. -Other tasks stay queued until the current one completes. - -Usage: - ./task-queue.py next # Get next task (returns JSON or empty) - ./task-queue.py status # Show queue status - ./task-queue.py clear # Clear completed tasks -""" - -import os import sys -import json +import argparse from pathlib import Path -from datetime import datetime - -DATACORE_ROOT = Path(os.environ.get("DATACORE_ROOT", Path.home() / "Data")) -MODULE_DIR = Path(__file__).parent.parent -STATE_FILE = MODULE_DIR / ".queue-state.json" - - -def get_username(): - """Get username from settings.""" - module_settings = MODULE_DIR / "settings.local.yaml" - if module_settings.exists(): - try: - import yaml - conf = yaml.safe_load(module_settings.read_text()) or {} - name = conf.get("identity", {}).get("name") - if name: - return name - except: - pass - return os.environ.get("USER", "unknown") - - -def get_state(): - """Load queue state.""" - if STATE_FILE.exists(): - try: - return json.loads(STATE_FILE.read_text()) - except: - pass - return {"current_task": None, "completed": []} - - -def save_state(state): - """Save queue state.""" - STATE_FILE.write_text(json.dumps(state, indent=2)) +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) -def get_pending_tasks(): - """Get all pending (unread) tasks from Claude inbox.""" - username = get_username() - tasks = [] - - for inbox in DATACORE_ROOT.glob(f"*/org/inboxes/{username}-claude.org"): - try: - content = inbox.read_text() - for block in content.split("\n* MESSAGE ")[1:]: - lines = block.split("\n") - header = lines[0] if lines else "" - - if ":unread:" not in header: - continue - - # Parse properties - props = {} - text_lines = [] - in_props = False - - for line in lines[1:]: - if ":PROPERTIES:" in line: - in_props = True - elif ":END:" in line: - in_props = False - elif in_props and ": " in line: - line = line.strip() - if line.startswith(":") and ": " in line[1:]: - kv = line[1:].split(": ", 1) - if len(kv) == 2: - props[kv[0].lower()] = kv[1] - elif not in_props and line.strip(): - text_lines.append(line) +from lib.config import get_username, get_default_space, datacore_root +from lib.agent_inbox import AgentInbox - msg_id = props.get("id", "") - if msg_id: - tasks.append({ - "id": msg_id, - "from": props.get("from", "?"), - "text": "\n".join(text_lines).strip(), - "priority": props.get("priority", "normal"), - "inbox": str(inbox) - }) - except: - pass - # Sort: high priority first, then by ID (chronological) - tasks.sort(key=lambda t: (0 if t["priority"] == "high" else 1, t["id"])) - return tasks +def main() -> None: + parser = argparse.ArgumentParser(description="Task queue management") + parser.add_argument("action", choices=["status", "approve", "reject", "cancel"], help="Queue action") + parser.add_argument("--task-id", help="Task ID (for approve/reject/cancel)") + parser.add_argument("--reason", default="", help="Reason (for reject/cancel)") + args = parser.parse_args() - -def get_working_tasks(): - """Get tasks currently being worked on.""" username = get_username() - tasks = [] - - for inbox in DATACORE_ROOT.glob(f"*/org/inboxes/{username}-claude.org"): - try: - content = inbox.read_text() - for block in content.split("\n* MESSAGE ")[1:]: - if ":TASK_STATUS: working" in block: - # Parse ID - for line in block.split("\n"): - if ":ID:" in line: - msg_id = line.split(":ID:")[1].strip() - tasks.append(msg_id) - break - except: - pass - - return tasks - - -def cmd_next(): - """Get next task to process.""" - state = get_state() - - # Check if there's already a task being worked on - working = get_working_tasks() - if working: - print(json.dumps({"status": "busy", "working": working[0]})) - return - - # Get pending tasks - pending = get_pending_tasks() - if not pending: - print(json.dumps({"status": "empty"})) - return - - # Return next task - next_task = pending[0] - state["current_task"] = next_task["id"] - save_state(state) - - print(json.dumps({ - "status": "ok", - "task": next_task, - "queued": len(pending) - 1 - })) - - -def cmd_status(): - """Show queue status.""" - working = get_working_tasks() - pending = get_pending_tasks() - state = get_state() - - print("Claude Task Queue Status") - print("=" * 40) - - if working: - print(f"🔄 Working: {working[0]}") - else: - print("🔄 Working: None") - - print(f"📋 Pending: {len(pending)} tasks") - for task in pending[:5]: - priority = "[!] " if task["priority"] == "high" else "" - text = task["text"][:40] + "..." if len(task["text"]) > 40 else task["text"] - print(f" {priority}{text}") - print(f" └─ from @{task['from']}") - - if len(pending) > 5: - print(f" ... and {len(pending) - 5} more") - - print(f"✓ Completed: {len(state.get('completed', []))}") - - -def cmd_clear(): - """Clear completed tasks from state.""" - state = get_state() - cleared = len(state.get("completed", [])) - state["completed"] = [] - save_state(state) - print(f"Cleared {cleared} completed tasks from state") - - -def main(): - if len(sys.argv) < 2: - print("Usage: task-queue.py ") - sys.exit(1) - - cmd = sys.argv[1] - if cmd == "next": - cmd_next() - elif cmd == "status": - cmd_status() - elif cmd == "clear": - cmd_clear() - else: - print(f"Unknown command: {cmd}") - sys.exit(1) + space = get_default_space() + space_root = datacore_root() / space + inbox = AgentInbox(space_root, f"{username}-claude") + + if args.action == "status": + counts = inbox.counts() + print(f"Task queue for {username}-claude:") + print(f" Queued: {counts['queued']}") + print(f" Working: {counts['working']}") + print(f" Waiting: {counts['waiting']}") + print(f" Done: {counts['done']}") + print(f" Total: {counts['total']}") + elif args.action == "approve" and args.task_id: + node = inbox.workspace.find_by_id(args.task_id) + if node: + inbox.approve(node) + print(f"[messaging] Approved: {args.task_id}") + elif args.action == "reject" and args.task_id: + node = inbox.workspace.find_by_id(args.task_id) + if node: + inbox.reject(node, reason=args.reason) + print(f"[messaging] Rejected: {args.task_id}") + elif args.action == "cancel" and args.task_id: + node = inbox.workspace.find_by_id(args.task_id) + if node: + inbox.cancel(node, reason=args.reason) + print(f"[messaging] Cancelled: {args.task_id}") if __name__ == "__main__": diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..efaf5c9 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,84 @@ +"""Tests for hooks using the new lib modules.""" +import sys +from pathlib import Path +import pytest + +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _fresh_config(): + clear_settings_cache() + yield + clear_settings_cache() + + +class TestInboxWatcher: + def test_no_tasks_returns_empty(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + queued = inbox.find_by_state("QUEUED") + working = inbox.find_by_state("WORKING") + assert len(queued) == 0 + assert len(working) == 0 + + def test_claims_next_queued_task(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + inbox.create_task("owner@x.com", "Research AI", "owner", ["AI"]) + queued = inbox.find_by_state("QUEUED") + assert len(queued) == 1 + inbox.claim(queued[0]) + assert queued[0].todo == "WORKING" + assert len(inbox.find_by_state("QUEUED")) == 0 + + def test_skips_when_task_already_working(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + t1 = inbox.create_task("owner@x.com", "Task 1", "owner", ["AI"]) + inbox.create_task("owner@x.com", "Task 2", "owner", ["AI"]) + inbox.claim(t1) + working = inbox.find_by_state("WORKING") + assert len(working) == 1 + + +class TestSendReply: + def test_creates_outgoing_message(self, tmp_space, msg_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.message_store import MessageStore + store = MessageStore(tmp_space, msg_state_config) + msg = store.create_message("gregor@x.com", "tex@x.com", "Hello!") + assert msg.todo == "TODO" + assert msg.properties["FROM"] == "gregor@x.com" + + +class TestMarkMessage: + def test_mark_read_removes_unread(self, tmp_space, msg_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.message_store import MessageStore + store = MessageStore(tmp_space, msg_state_config) + msg = store.create_message("a@x.com", "b@x.com", "test") + assert "unread" in msg.shallow_tags + store.mark_read(msg) + assert "unread" not in msg.shallow_tags + assert msg.todo == "DONE" + + +class TestTaskQueue: + def test_counts_reflect_state(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + inbox.create_task("owner@x.com", "T1", "owner", ["AI"]) + inbox.create_task("unknown@x.com", "T2", "unknown", ["AI"]) + counts = inbox.counts() + assert counts["queued"] == 1 + assert counts["waiting"] == 1 + assert counts["total"] == 2 From 9bf051d81f5c30a76a586bd525c581dc7828a371 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 18:17:52 +0100 Subject: [PATCH 10/17] =?UTF-8?q?test:=20add=20integration=20tests=20?= =?UTF-8?q?=E2=80=94=20message-to-task=20flow,=20governance=20blocks,=20st?= =?UTF-8?q?ore=20independence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- ...claude-inbox.md => message-task-intake.md} | 0 tests/test_integration.py | 97 +++++++++++++++++++ 2 files changed, 97 insertions(+) rename agents/{claude-inbox.md => message-task-intake.md} (100%) create mode 100644 tests/test_integration.py diff --git a/agents/claude-inbox.md b/agents/message-task-intake.md similarity index 100% rename from agents/claude-inbox.md rename to agents/message-task-intake.md diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..bd661e6 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,97 @@ +"""End-to-end test: message creation, agent task lifecycle, governance.""" + +import pytest +from pathlib import Path + +from lib.config import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _fresh_config(): + clear_settings_cache() + yield + clear_settings_cache() + + +@pytest.fixture +def full_setup(tmp_space, msg_state_config, task_state_config): + """Set up message store, agent inbox, and governor together.""" + from lib.message_store import MessageStore + from lib.agent_inbox import AgentInbox + from lib.governor import TaskGovernor + + state_dir = tmp_space / ".datacore" / "state" / "messaging" + state_dir.mkdir(parents=True) + + return { + "store": MessageStore(tmp_space, state_config=msg_state_config), + "agent": AgentInbox(tmp_space, "test-claude", state_config=task_state_config), + "governor": TaskGovernor(state_dir=state_dir), + "space": tmp_space, + } + + +class TestMessageToTaskFlow: + def test_unknown_sender_needs_approval(self, full_setup): + """An unknown sender's task goes to WAITING for owner approval.""" + agent = full_setup["agent"] + gov = full_setup["governor"] + + check = gov.check_task("tex@team.com", estimated_tokens=5000) + assert check.allowed is True + assert check.trust_tier == "unknown" + + task = agent.create_task( + from_actor="tex@team.com", + content="Research competitors", + trust_tier=check.trust_tier, + tags=["AI", "research"], + ) + assert task.todo == "WAITING" + + def test_full_task_lifecycle(self, full_setup): + """Owner task: auto-accept -> claim -> complete -> archive.""" + agent = full_setup["agent"] + gov = full_setup["governor"] + + task = agent.create_task( + from_actor="owner@x.com", + content="Quick research", + trust_tier="owner", + tags=["AI"], + ) + assert task.todo == "QUEUED" + + agent.claim(task) + assert task.todo == "WORKING" + + agent.complete(task, tokens_used=800) + assert task.todo == "DONE" + + gov.record_usage("owner@x.com", tokens=800) + usage = gov.get_usage("owner@x.com") + assert usage["tokens_today"] == 800 + + +class TestGovernorBlocksAbuse: + def test_rate_limited_sender_blocked(self, full_setup): + gov = full_setup["governor"] + + for i in range(5): + gov.record_task_submission("spammer@x.com") + + check = gov.check_task("spammer@x.com", estimated_tokens=100) + assert check.allowed is False + + +class TestMessageStoreIndependence: + def test_messages_and_tasks_coexist(self, full_setup): + """Messages in inbox.org and tasks in agent/*.org don't interfere.""" + store = full_setup["store"] + agent = full_setup["agent"] + + store.create_message("a@x.com", "b@x.com", "Hello") + agent.create_task("a@x.com", "Do work", "owner", ["AI"]) + + assert len(store.find_unread()) == 1 + assert len(agent.find_by_state("QUEUED")) == 1 From c7c9e49927acf93f634b34b5c76c6c32e6765b4c Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 18:20:01 +0100 Subject: [PATCH 11/17] docs: update module manifest, agent specs, and CLAUDE.md to match v0.2.0 architecture Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 219 ++++++++++++++++++++++++--------- agents/message-digest.md | 14 ++- agents/message-task-intake.md | 223 ++++++++++++++++------------------ module.yaml | 94 ++++++++++++-- 4 files changed, 357 insertions(+), 193 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a3d9ed..9e49b2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # Messaging Module Context -This module adds inter-user messaging to Datacore via shared space inboxes. +This module adds inter-user and human-to-agent messaging to Datacore via org-workspace backed storage. ## Overview -Messages are org-mode entries stored in `[space]/org/inboxes/[username].org`. Users send messages with `/msg`, read with `/my-messages`, and reply with `/reply`. +Messages are org-mode entries managed by `lib/message_store.py` and stored in `[space]/org/messaging/inbox.org`. Users send messages with `/msg`, read with `/my-messages`, and reply with `/reply`. Messages addressed to `@claude` are routed as agent tasks with trust tier enforcement. ## Key Concepts @@ -18,37 +18,98 @@ identity: handles: ["@gregor", "@gz"] # Aliases for receiving messages ``` -### Inbox Location +### Storage Layout ``` -[space]/org/inboxes/ -├── USERS.yaml # Registry of all users and handles -├── gregor.org # Gregor's inbox -├── crt.org # Črt's inbox -└── claude.org # AI task inbox (special) +[space]/org/messaging/ +├── inbox.org # Universal inbox (all incoming messages and file deliveries) +├── outbox.org # Sent message log +└── agents/ + ├── gregor-claude.org # Gregor's Claude agent task inbox + └── crt-claude.org # Črt's Claude agent task inbox ``` +Agent inbox filenames are `{username}-claude.org` — one per user. There is no generic `claude.org`. + ### Message Format +Messages in `inbox.org` use TODO state with `:message:` and `:unread:` tags: + ```org -* MESSAGE [2025-12-11 Thu 13:00] :unread: +* TODO [2025-12-11 Thu 13:00] :unread:message: :PROPERTIES: -:ID: msg-{timestamp}-{sender} -:FROM: sender_name -:TO: recipient_name -:PRIORITY: normal|high|low -:THREAD: nil|parent_message_id +:ID: msg-20251211-130000-a1b2c3d4 +:FROM: sender_name +:TO: recipient_name +:REPLY_TO: nil +:THREAD: nil :END: Message content here. ``` +File deliveries use the `:file_delivery:` tag instead of `:message:`. + +### State Machine + +Messages and tasks go through org-workspace states: + +**Messages** (`inbox.org`): +- `TODO` (unread) → `DONE` (read) → `ARCHIVED` + +**Agent tasks** (`agents/{username}-claude.org`): +- `WAITING` → `QUEUED` → `WORKING` → `DONE` → `ARCHIVED` +- Terminal states: `CANCELLED`, `ARCHIVED` + ### Tags -- `:unread:` - Not yet viewed -- `:read:` - Viewed by recipient -- `:replied:` - Recipient has replied -- `:from-ai:` - Response from Claude -- `:AI:` - Task for AI processing (claude.org only) +- `:unread:` + `TODO` state — not yet viewed +- `:message:` — standard text message +- `:file_delivery:` — file delivered via Fairdrop/Swarm +- `:AI:` — task for agent processing +- `:AI:research:`, `:AI:content:`, `:AI:data:`, `:AI:pm:` — routed subtypes + +## lib/ Modules + +All message operations go through the lib modules. No direct file manipulation. + +| Module | Purpose | +|--------|---------| +| `lib/config.py` | Settings, identity, trust tier resolution, compute config | +| `lib/message_store.py` | CRUD for `inbox.org` — create, find, mark_read, archive | +| `lib/agent_inbox.py` | Task lifecycle for `agents/{username}-claude.org` | +| `lib/governor.py` | Trust tier enforcement, token budgets, rate limits | +| `lib/relay.py` | WebSocket relay client for cross-instance messaging | + +### MessageStore + +```python +store = MessageStore(space_root) +msg = store.create_message(from_actor="gregor", to_actor="crt", content="Hello") +unread = store.find_unread() # reloads from disk +store.mark_read(unread[0]) # TODO -> DONE +store.archive(unread[0]) # DONE -> ARCHIVED +``` + +### AgentInbox + +```python +inbox = AgentInbox(space_root, "gregor-claude") +task = inbox.create_task(from_actor="gregor", content="Research X", trust_tier="owner", tags=["AI", "research"]) +queued = inbox.find_by_state("QUEUED") +inbox.claim(queued[0]) # QUEUED -> WORKING +inbox.complete(queued[0], tokens_used=1200) # WORKING -> DONE +``` + +### TaskGovernor + +```python +gov = TaskGovernor(state_dir=Path(".datacore/state/messaging")) +result = gov.check_and_record("gregor", estimated_tokens=5000, effort=3) +if result.allowed: + # create task + gov.record_usage("gregor", tokens=1150) + gov.record_task_completion("gregor") +``` ## Commands @@ -57,9 +118,9 @@ Message content here. Send a message to another user. **Resolution order for recipient:** -1. Check USERS.yaml for handle → username mapping +1. Check `contacts.yaml` for handle → username mapping 2. If not found, treat handle as username -3. If user doesn't exist, create inbox and add to USERS.yaml +3. If user doesn't exist in contacts, create inbox entry and add to `contacts.yaml` **Space selection:** 1. Explicit: `--space datafund` @@ -71,68 +132,108 @@ Send a message to another user. Display inbox for current user. **Steps:** -1. Read `identity.name` from settings -2. Find all `*/org/inboxes/{name}.org` files -3. Parse org entries, filter by tags -4. Display grouped by space, sorted by time +1. Read `identity.name` from settings via `lib/config.get_username()` +2. Open `[space]/org/messaging/inbox.org` via `MessageStore` +3. Call `MessageStore.find_unread()` — returns org-workspace NodeViews +4. Display grouped by sender, sorted by timestamp ### /reply Reply to a message, creating a thread. **Steps:** -1. Find original message by ID (or "last") -2. Create new message with `THREAD` property set to original ID -3. Append to sender's inbox (reverse direction) -4. Add `:replied:` tag to original message +1. Find original message by ID (or "last") via `MessageStore.find_by_id()` +2. Create reply via `MessageStore.create_message(reply_to=original_id)` +3. `REPLY_TO` and `THREAD` properties are set automatically by `message_store.py` +4. Mark original as read via `MessageStore.mark_read()` ## Claude Integration -Messages to `@claude` are special: -1. Stored in `[space]/org/inboxes/claude.org` -2. Tagged with `:AI:` for ai-task-executor -3. Agent processes and sends reply to sender's inbox -4. Reply tagged `:from-ai:` +Messages to `@claude` follow the agent task pathway: +1. Message arrives in `org/messaging/inbox.org` with `:AI:` tag +2. `inbox-watcher.py` hook routes it to the `AgentInbox` for `{username}-claude` +3. Trust tier checked by `TaskGovernor` — auto-accept for owner/team, WAITING for others +4. Task entered as QUEUED or WAITING in `org/messaging/agents/{username}-claude.org` +5. Agent claims task (QUEUED → WORKING), executes, completes +6. Reply sent back to sender via `MessageStore.create_message()` -## File Operations +See `agents/message-task-intake.md` for full agent specification. -### Creating a message - -```python -# Append to recipient's inbox -with open(f"{space}/org/inboxes/{recipient}.org", "a") as f: - f.write(message_org_format) -``` - -### Marking as read - -Replace `:unread:` tag with `:read:` in the heading line. - -### User registry +## Contacts Registry ```yaml -# USERS.yaml -users: - username: - handles: ["@handle1", "@handle2"] - added: YYYY-MM-DD +# contacts.yaml +contacts: + gregor: + handles: ["@gregor", "@gz"] + relay: "wss://relay.example.com/ws" + added: "2025-12-11" + crt: + handles: ["@crt"] + relay: "" + added: "2025-12-11" ``` +Previously `USERS.yaml` — renamed to `contacts.yaml` in v0.2.0. + ## Settings Schema ```yaml identity: - name: string # Required - handles: [string] # Optional, defaults to ["@{name}"] + name: string # Required + handles: [string] # Optional, defaults to ["@{name}"] messaging: - default_space: string # Optional, defaults to first team space - show_in_today: bool # Optional, defaults to true - auto_mark_read: bool # Optional, defaults to false + default_space: string # Optional, defaults to "0-personal" + show_in_today: bool # Optional, defaults to true + auto_mark_read: bool # Optional, defaults to false + + relay: + url: string # WebSocket relay URL + secret: string # Relay auth secret + + trust_tiers: # Per-tier config overrides + owner: + priority_boost: 2.0 + daily_token_limit: 0 # 0 = unlimited + max_task_effort: 0 # 0 = unlimited + auto_accept: true + team: + priority_boost: 1.5 + daily_token_limit: 100000 + max_task_effort: 8 + auto_accept: true + trusted: + priority_boost: 1.0 + daily_token_limit: 50000 + max_task_effort: 5 + auto_accept: false + unknown: + priority_boost: 0.5 + daily_token_limit: 10000 + max_task_effort: 3 + auto_accept: false + + trust_overrides: # Per-actor tier assignments + crt: team + external-partner: trusted + + compute: + daily_budget_tokens: 500000 + per_sender_daily_max: 100000 + per_task_max_tokens: 50000 + per_task_timeout_minutes: 30 + max_queue_depth: 20 + rate_limits: + tasks_per_hour: 5 + + inbox_feeds: [] # External relay WebSocket endpoints to poll ``` ## Error Handling -- **Unknown recipient**: Create inbox, add to USERS.yaml, warn user +- **Unknown recipient**: Create inbox entry, add to `contacts.yaml`, warn user - **No identity configured**: Prompt to add `identity.name` to settings - **Space not found**: List available spaces, ask user to specify +- **Governance rejected**: Reply to sender with rejection reason and current budget status +- **Relay unreachable**: Queue messages locally, retry on next connection diff --git a/agents/message-digest.md b/agents/message-digest.md index 89a9a0c..1855edc 100644 --- a/agents/message-digest.md +++ b/agents/message-digest.md @@ -13,14 +13,14 @@ Called by `/today` command when `messaging.show_in_today: true` (default). ## Behavior 1. **Get current user identity** - - Read `identity.name` from settings + - Read `identity.name` from settings via `lib/config.get_username()` -2. **Scan all inboxes** - - Find `*/org/inboxes/{identity.name}.org` in all spaces +2. **Scan Universal Inbox** + - Open `[space]/org/messaging/inbox.org` for each configured space + - Use org-workspace query API: `MessageStore.find_unread()` returns nodes in TODO state with `:unread:` tag 3. **Count unread messages** - - Parse org entries with `:unread:` tag - - Group by sender + - Group nodes by `:FROM:` property - Sort by count (descending) 4. **Generate summary** @@ -70,10 +70,12 @@ The `/today` command should: 1. Check if messaging module is installed 2. If `messaging.show_in_today: true`: - - Call this agent + - Call this agent (via `hooks/inbox-watcher.py`) - Include output in briefing after "Calendar" section 3. If no messages, can optionally omit section entirely +The agent reads from `org/messaging/inbox.org` using `MessageStore.find_unread()`. No direct file parsing — all access goes through `lib/message_store.py`. + ## Example Integration in /today ```markdown diff --git a/agents/message-task-intake.md b/agents/message-task-intake.md index c494949..45b090a 100644 --- a/agents/message-task-intake.md +++ b/agents/message-task-intake.md @@ -1,173 +1,160 @@ -# Claude Inbox Agent +# Message Task Intake Agent -Processes messages sent to `@claude` and routes them as AI tasks. +Receives messages addressed to Claude, validates them through TaskGovernor, and routes them as agent tasks via the org-workspace state machine. ## Purpose -When users send messages to `@claude`, this agent: -1. Parses the message as an AI task -2. Routes to appropriate specialized agent -3. Sends results back to the sender's inbox +When users send messages to `@claude` (or the local Claude instance), this agent: +1. Validates the sender against trust tiers via TaskGovernor +2. Creates a task entry in the agent inbox with the appropriate initial state +3. Routes to the correct specialized agent based on message content and tags +4. Sends results back to the sender's inbox when the task completes ## Trigger -- Called by `ai-task-executor` when processing `claude.org` inbox -- Messages have `:AI:` tag in addition to `:unread:` +- Called by `ai-task-executor` when processing `org/messaging/agents/{username}-claude.org` +- Tasks in QUEUED state are claimed on the next prompt submit (via `hooks/inbox-watcher.py`) +- Tasks in WAITING state require owner approval before execution ## Inbox Location ``` -[space]/org/inboxes/claude.org +[space]/org/messaging/agents/{username}-claude.org ``` -## Message Format (Input) +The filename is `{username}-claude.org` where `username` is `identity.name` from settings. There is no generic `claude.org` — each user's local Claude instance has its own per-user file. -```org -* MESSAGE [2025-12-11 Thu 14:30] :unread:AI: -:PROPERTIES: -:ID: msg-20251211-143000-gregor -:FROM: gregor -:TO: claude -:PRIORITY: normal -:END: -Research competitor pricing for Verity and add findings to research/ -``` +## Task State Machine -## Behavior +States follow DIP-0023 Section 5.2, managed by `lib/agent_inbox.py` via org-workspace: -1. **Parse message** - - Extract sender from `:FROM:` - - Extract task description from body - - Determine task type from content - -2. **Route to specialized agent** +``` +WAITING -> QUEUED (owner approves via task-queue.py approve) +WAITING -> CANCELLED (owner rejects via task-queue.py reject) +QUEUED -> WORKING (agent claims via inbox-watcher.py on prompt submit) +QUEUED -> CANCELLED (owner cancels via task-queue.py cancel) +WORKING -> DONE (execution completes) +WORKING -> QUEUED (retry on failure) +DONE -> ARCHIVED (owner approves result) +DONE -> QUEUED (owner requests revision) +``` - | Content Pattern | Agent | Tag | - |-----------------|-------|-----| - | "research", URL | gtd-research-processor | :AI:research: | - | "write", "draft", "create content" | gtd-content-writer | :AI:content: | - | "analyze", "report", "metrics" | gtd-data-analyzer | :AI:data: | - | "track", "status", "blockers" | gtd-project-manager | :AI:pm: | - | Default | general processing | :AI: | +DONE is **not** terminal — it is an active review state. Only CANCELLED and ARCHIVED are terminal. -3. **Execute task** - - Pass message content to specialized agent - - Capture output/results +### Auto-accept by trust tier -4. **Send reply to sender** - - Create message in sender's inbox - - Include task results or summary - - Tag with `:from-ai:` - - Reference original message in `:THREAD:` +Trust tiers configured in `messaging.trust_tiers` (and default overrides in `lib/config.py`): -5. **Mark original as processed** - - Change `:unread:` to `:processed:` - - Add `:done:` tag +| Tier | auto_accept | Initial state | +|------|-------------|---------------| +| owner | true | QUEUED | +| team | true | QUEUED | +| trusted | false | WAITING | +| unknown | false | WAITING | -## Reply Format (Output) +## Task Format (org-workspace entry) ```org -* MESSAGE [2025-12-11 Thu 15:00] :unread:from-ai: +* QUEUED Research competitor pricing for Verity :AI:research: :PROPERTIES: -:ID: msg-20251211-150000-claude -:FROM: claude -:TO: gregor -:THREAD: msg-20251211-143000-gregor -:TASK_STATUS: completed +:ID: msg-20251211-143000-a1b2c3d4 +:FROM: gregor +:TRUST_TIER: owner +:SUBMITTED: [2025-12-11 Thu 14:30] +:APPROVAL: auto_accepted +:EFFORT: 3 :END: -✓ Research completed - -Added 3 competitor analyses: -- research/competitor-chainlink.md -- research/competitor-ocean.md -- research/competitor-streamr.md - -**Summary:** -Chainlink: Enterprise focus, $50K+ deals -Ocean Protocol: Token-based access, DeFi integration -Streamr: Real-time data streams, pub/sub model - -Verity differentiation: RWA tokenization + provenance verification ``` -## Error Handling - -If task fails: +For tasks requiring approval (trust tier without auto_accept): ```org -* MESSAGE [2025-12-11 Thu 15:00] :unread:from-ai: +* WAITING Draft blog post about data tokenization :AI:content: :PROPERTIES: -:ID: msg-20251211-150000-claude -:FROM: claude -:TO: gregor -:THREAD: msg-20251211-143000-gregor -:TASK_STATUS: failed +:ID: msg-20251211-144500-e5f6a7b8 +:FROM: external-user +:TRUST_TIER: unknown +:SUBMITTED: [2025-12-11 Thu 14:45] +:AWAITING: owner-approval +:ESTIMATED_TOKENS: 5000 +:ESTIMATED_COST: $0.08 :END: -⚠️ Task could not be completed +``` -Error: Could not access URL https://example.com (403 Forbidden) +## Agent Routing -Original request: Research competitor pricing... +Routing is based on org-mode tags present in the message entry: -Please verify the URL is accessible or provide alternative sources. -``` +| Tag | Agent | Description | +|-----|-------|-------------| +| `:AI:research:` | research-orchestrator | URL or topic research | +| `:AI:content:` | gtd-content-writer | Blog posts, emails, social copy | +| `:AI:data:` | gtd-data-analyzer | Analysis, metrics, reports | +| `:AI:pm:` | gtd-project-manager | Project status, blockers | +| `:AI:` (no subtype) | general processing | Default task execution | -## Example Interactions +Tag patterns are matched from the task entry's org tags. A task with `:AI:research:` will be routed to `research-orchestrator` regardless of message phrasing. -### Research Request +## Governance (TaskGovernor) -**User sends:** -``` -/msg @claude "Research MCP protocol best practices and create summary in notes/" -``` +Before accepting a task, `lib/governor.py` checks: +- **Effort limit**: task effort vs. tier's `max_task_effort` +- **Per-sender token budget**: `sender.tokens_today` vs. tier's `daily_token_limit` +- **Global daily budget**: total tokens across all senders vs. `compute.daily_budget_tokens` +- **Rate limit**: tasks submitted in the last hour vs. `compute.rate_limits.tasks_per_hour` +- **Queue depth**: active tasks vs. `compute.max_queue_depth` -**Claude replies:** -``` -✓ Research completed +If governance check fails, the task is rejected and a reply is sent to the sender explaining the reason. -Created: notes/mcp-protocol-best-practices.md +## Reply Format -Key findings: -- Security: Use scoped permissions, validate all inputs -- Performance: Batch requests, cache responses -- UX: Provide clear error messages, progress indicators -``` +When a task completes, a message is created in the sender's inbox via `MessageStore.create_message()`: -### Content Request +```org +* TODO [2025-12-11 Thu 15:00] :unread:message: +:PROPERTIES: +:ID: msg-20251211-150000-c9d0e1f2 +:FROM: claude +:TO: gregor +:REPLY_TO: msg-20251211-143000-a1b2c3d4 +:THREAD: msg-20251211-143000-a1b2c3d4 +:END: +Research completed. -**User sends:** -``` -/msg @claude "Draft a blog post about data tokenization benefits" +Added 3 competitor analyses to research/: +- research/competitor-chainlink.md +- research/competitor-ocean.md +- research/competitor-streamr.md ``` -**Claude replies:** -``` -✓ Draft created +## Approval Workflow + +For WAITING tasks, the owner uses `hooks/task-queue.py`: -File: content/drafts/data-tokenization-benefits.md +```bash +# View pending approvals +python hooks/task-queue.py status -Outline: -1. Introduction to data as an asset -2. Traditional vs tokenized data ownership -3. Benefits: liquidity, fractional ownership, provenance -4. Use cases: healthcare, finance, research -5. Getting started with Verity +# Approve a task +python hooks/task-queue.py approve --task-id msg-20251211-144500-e5f6a7b8 -Ready for your review and edits. +# Reject a task +python hooks/task-queue.py reject --task-id msg-20251211-144500-e5f6a7b8 --reason "Not relevant" ``` ## Integration with ai-task-executor The `ai-task-executor` should: - -1. Check `[space]/org/inboxes/claude.org` for `:unread:AI:` messages -2. Process each message through this agent -3. Ensure replies are sent back -4. Log completion to journal +1. Check `[space]/org/messaging/agents/{username}-claude.org` for QUEUED tasks +2. Claim the first QUEUED task (transitions to WORKING via `AgentInbox.claim()`) +3. Execute via the appropriate routed agent +4. Call `AgentInbox.complete()` with token usage and result path +5. Send reply to sender via `MessageStore.create_message()` +6. Record token usage via `TaskGovernor.record_usage()` ## Notes -- Messages to @claude are processed during AI task execution cycles -- Not real-time - delivery depends on when ai-task-executor runs -- Complex tasks may be broken into subtasks -- Results always sent back to sender's inbox +- Tasks are not real-time — execution depends on when ai-task-executor or inbox-watcher runs +- Complex tasks may be broken into subtasks; each subtask creates its own entry +- Token usage is tracked per-sender per-day for budget enforcement +- The `{username}-claude.org` file is created automatically by `AgentInbox._ensure_file()` diff --git a/module.yaml b/module.yaml index c9e7a7b..bbc6bdb 100644 --- a/module.yaml +++ b/module.yaml @@ -2,14 +2,15 @@ # https://github.com/datafund/datacore-messaging name: messaging -version: 0.1.0 -description: Inter-user messaging via shared space inboxes +version: 0.2.0 +description: Inter-user and human-to-agent messaging via org-workspace state machine author: datafund repository: https://github.com/datafund/datacore-messaging # Dependencies (spec-compliant format) dependencies: - core@>=0.1.0 + - org-workspace>=0.3.0 # What this module provides (spec-compliant format) provides: @@ -21,7 +22,7 @@ provides: - broadcast agents: - message-digest - - claude-inbox + - message-task-intake # Settings schema (extension - not in spec but useful) settings: @@ -50,20 +51,93 @@ settings: default: false description: Automatically mark messages as read after viewing + relay: + url: + type: string + required: false + description: WebSocket URL for the relay server + secret: + type: string + required: false + description: Authentication secret for relay connection + + trust_tiers: + type: object + required: false + description: Per-tier overrides for priority_boost, daily_token_limit, max_task_effort, auto_accept + trust_overrides: + type: object + required: false + description: Per-actor trust tier assignments (actor_id -> tier name) + + compute: + daily_budget_tokens: + type: integer + default: 500000 + description: Global daily token budget across all senders + per_sender_daily_max: + type: integer + default: 100000 + description: Per-sender daily token limit (override via trust_tiers) + per_task_max_tokens: + type: integer + default: 50000 + description: Hard cap on tokens per task + per_task_timeout_minutes: + type: integer + default: 30 + description: Task execution timeout in minutes + max_queue_depth: + type: integer + default: 20 + description: Maximum number of concurrent active tasks + rate_limits: + tasks_per_hour: + type: integer + default: 5 + description: Maximum tasks accepted per actor per hour + + inbox_feeds: + type: array + items: string + default: [] + description: External feed URLs to poll for incoming messages (relay WebSocket endpoints) + # Files created by this module (extension) creates: - - path: "{space}/org/inboxes/" + - path: "{space}/org/messaging/" + type: directory + description: Messaging directory for each space + - path: "{space}/org/messaging/inbox.org" + type: file + description: Universal inbox (all incoming messages and file deliveries) + - path: "{space}/org/messaging/outbox.org" + type: file + description: Sent message log + - path: "{space}/org/messaging/agents/" type: directory - description: Inbox directory for each space - - path: "{space}/org/inboxes/USERS.yaml" + description: Per-agent task inboxes + - path: "{space}/org/messaging/agents/{username}-claude.org" type: file - description: User registry for the space - - path: "{space}/org/inboxes/{username}.org" + description: Agent task inbox for the local Claude instance + - path: "{space}/contacts.yaml" type: file - description: Individual user inbox + description: Contact registry mapping handles to usernames and relay endpoints # Integration hooks (extension) hooks: today: description: Add unread message count to daily briefing - handler: agents/message-digest.md + handler: hooks/inbox-watcher.py + task_check: + description: Check for queued agent tasks on prompt submit + handler: hooks/inbox-watcher.py + send_reply: + description: Send reply message to a thread + handler: hooks/send-reply.py + mark_message: + description: Mark a message read/archived + handler: hooks/mark-message.py + task_queue: + description: Approve, reject, or cancel queued tasks + handler: hooks/task-queue.py From b4fdc34ce655c508606069c1b5aac47439e817cf Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 18:21:47 +0100 Subject: [PATCH 12/17] =?UTF-8?q?chore:=20cleanup=20=E2=80=94=20remove=20l?= =?UTF-8?q?egacy=20files,=20add=20contacts=20template,=20update=20README?= =?UTF-8?q?=20for=20v0.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- README.md | 314 ++++++++--------- lib/datacore-msg-window.py | 692 ------------------------------------- templates/USERS.yaml | 17 - templates/contacts.yaml | 8 + 4 files changed, 148 insertions(+), 883 deletions(-) delete mode 100755 lib/datacore-msg-window.py delete mode 100644 templates/USERS.yaml create mode 100644 templates/contacts.yaml diff --git a/README.md b/README.md index 495e2e0..05dc6f7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ # Datacore Module: Messaging -Real-time team messaging with Claude Code integration. +Inter-user and human-to-agent messaging via org-workspace state machine. ## Features -- **GUI Window**: Floating always-on-top window for sending/receiving messages -- **Claude Code integration**: Message `@claude` to delegate AI tasks to your personal Claude -- **Namespaced agents**: `@tex-claude`, `@gregor-claude` - each user has their own Claude -- **Whitelist control**: Choose who can message your Claude (others get auto-reply) -- **WebSocket relay**: Real-time delivery via `datacore-messaging-relay.datafund.ai` -- **Local storage**: Messages saved as org-mode entries for offline access +- **Team messaging**: Send/receive messages between Datacore users +- **Agent inbox**: `@yourname-claude` receives tasks with full lifecycle governance +- **Task governance**: Trust tiers, per-sender budgets, rate limiting, queue depth control +- **org-workspace backed**: All state in org-mode files under `org/messaging/` +- **Relay transport**: Real-time delivery via configurable WebSocket relay +- **Claude Code hook**: Agent tasks surface on next prompt; Claude replies inline + +## Requirements + +- Python 3.10+ +- [org-workspace](https://github.com/datafund/org-workspace) >= 0.3.0 +- websockets, pyyaml, aiohttp, filelock ## Installation @@ -21,7 +27,7 @@ cd messaging ``` The installer will: -- Install Python dependencies (PyQt6, websockets, pyyaml, aiohttp) +- Install Python dependencies (see `requirements.txt`) - Create `settings.local.yaml` from template - Add Claude Code hook to `~/.claude/settings.json` @@ -31,40 +37,78 @@ Edit `settings.local.yaml`: ```yaml identity: - name: yourname # Your username (required) + name: yourname # Your username (required) messaging: - default_space: 1-datafund # Space for message inboxes - - claude_whitelist: # Who can message @yourname-claude - - gregor - - crt + default_space: 1-datafund # Space where org/messaging/ lives relay: - secret: "your-team-secret" # Same for all team members - url: "wss://datacore-messaging-relay.datafund.ai/ws" + url: "wss://your-relay-host/ws" # Configurable relay endpoint + secret: "your-team-secret" # Shared secret for relay auth + + trust_tiers: # Optional: override per-tier defaults + team: + daily_token_limit: 200000 + unknown: + auto_accept: false + + trust_overrides: # Optional: per-actor tier assignment + "tex@team.example.com": team + "stranger@other.com": unknown ``` +See `settings.local.yaml.example` for all options. + +## Storage Layout + +All messaging state lives under the space's `org/messaging/` directory: + +``` +{space}/org/messaging/ +├── inbox.org # Universal inbox (all incoming messages) +├── outbox.org # Sent message log +└── agents/ + └── {username}-claude.org # Agent task inbox (task state machine) +``` + +Messages are org-mode headings with property drawers. The agent inbox uses +org-workspace's state machine: `WAITING → QUEUED → WORKING → DONE → ARCHIVED`. + +## Contacts + +Known actors are stored in `templates/contacts.yaml` (copy to your space): + +```yaml +actors: + - id: "tex@team.example.com" + name: "Tex" + trust_tier: team + added: 2026-03-11 +``` + +Add actors via `/msg-trust` or edit the file directly. + ## Usage -### Start the GUI +### Send a Message + +```bash +python3 datacore-msg.py send @gregor "Hey, can you review the PR?" +python3 datacore-msg.py send @tex-claude "Research competitor pricing" +``` + +### Check Inbox ```bash -./start.sh -# Or directly: -python3 datacore-msg.py +python3 datacore-msg.py inbox ``` -### Send Messages +### Start the GUI -In the GUI input field: -- `@gregor Hey, can you review the PR?` - Message a teammate -- `@claude Research competitor pricing` - Message your Claude agent -- `@gregor-claude Help with code review` - Message someone else's Claude (if whitelisted) -- `@gregor >msg-id Follow-up here` - Reply to a message (creates thread) -- `@claude [github:42] Fix this bug` - Route response to GitHub issue -- `@claude [file:research/report.md] Analyze this` - Route to file -- `@claude [@gregor] Help with code` - CC response to another user +```bash +python3 datacore-msg.py gui +# Or: ./start.sh +``` ### GUI Commands @@ -74,176 +118,95 @@ Type in the input field: |---------|-------------| | `/mine` | Show my unread messages | | `/todos` | Show my TODO messages | -| `/tasks` | Show Claude task queue (working/pending/done) | -| `/context ` | Show thread context for a message | -| `/online` | Show online users with status | -| `/status` | Show your current status | -| `/status ` | Set status: online, busy, away, focusing | +| `/tasks` | Show Claude task queue | +| `/context ` | Show thread context | +| `/online` | Show online users | +| `/status [val]` | Get/set status | | `/relay` | Show relay connection info | | `/clear` | Clear display | | `/help` | Show available commands | -**Presence Status:** -- 🟢 online - Available -- 🔴 busy - Do not disturb -- 🟡 away - Stepped away -- 🟣 focusing - Deep work mode - -**Clickable messages:** -- Click on a message to cycle: unread → todo → done → clear -- Use the checkbox to mark as done -- Messages show status: ● unread, ☐ todo, ✓ done +## Agent Inbox (Claude Code Integration) -### GUI Features +Claude Code doesn't maintain a persistent relay connection. Instead, a hook +checks the agent inbox each time you submit a prompt. -- Always-on-top floating window -- Dark theme -- Real-time message updates -- Online users count -- System notifications (macOS) -- `@claude` automatically routes to your personal `@yourname-claude` +### Task Lifecycle ``` -┌─ Messages @tex ──────────── ● relay ─┐ -│ 2 online │ -│ ● @gregor 14:30 │ -│ Need OAuth keys - see issue #25 │ -│ │ -│ ● @tex-claude 14:35 │ -│ Research complete. See research/ │ -│ │ -│ @you→gregor 14:40 │ -│ Keys are in the vault │ -│ │ -├───────────────────────────────────────┤ -│ @gregor message here... │ -├───────────────────────────────────────┤ -│ Space: 1-datafund │ -└───────────────────────────────────────┘ +WAITING -> sender submits a task request +QUEUED -> owner approves (auto-accept for trusted tiers) +WORKING -> Claude claims and begins execution +DONE -> execution complete, result posted +ARCHIVED -> owner confirms result +CANCELLED -> rejected or timed out at any stage ``` -## Claude Code Integration - -### How It Works - -**Important**: Claude Code doesn't have a persistent connection to the relay. Messages are checked via a hook that runs when you submit a prompt to Claude. - -Flow: -1. Someone sends `@tex-claude do something` in GUI -2. Message is stored in `tex-claude.org` with `:unread:` tag -3. When you next interact with Claude Code, the hook checks the inbox -4. Unread messages are shown to Claude and marked as read -5. Claude can reply using `send-reply.py` +### How Claude Receives Tasks -### Receiving Messages +1. Sender sends `@tex-claude do something` via GUI or CLI +2. Message stored in `{space}/org/messaging/agents/tex-claude.org` +3. Governor checks trust tier — auto-accepts or holds for approval +4. On next Claude Code prompt, hook surfaces the queued task +5. Claude executes and replies via `hooks/send-reply.py` -The installer adds a hook that shows new messages when you interact with Claude: +### Hook Output ``` -📬 New messages for @tex-claude: +📬 New task for @tex-claude: -From @gregor (14:30): +From @gregor (14:30) [QUEUED]: Can you help debug the auth flow? [msg-id: msg-20251212-143000-gregor] --- -To reply: use hooks/send-reply.py -Messages above are now marked as read. +To reply: hooks/send-reply.py ``` -After displaying, messages are marked as read (`:unread:` tag removed from org file). +### Task Governance + +The governor enforces resource limits per sender before accepting tasks: + +- **Trust tiers**: `owner`, `team`, `trusted`, `unknown` +- **Daily token budgets**: per-tier and global caps +- **Rate limits**: tasks per hour per actor +- **Queue depth**: max concurrent active tasks +- **Auto-accept**: trusted tiers bypass approval queue + +Configure via `trust_tiers` and `trust_overrides` in settings. ### Sending Replies from Claude ```bash -# Claude can reply via the send-reply script +# Reply to a sender python3 hooks/send-reply.py gregor "Fixed! Check the PR." # Reply to a specific message (creates thread) -python3 hooks/send-reply.py --reply-to msg-20251212-143000-gregor gregor "Here's the follow-up" +python3 hooks/send-reply.py --reply-to msg-20251212-143000-gregor gregor "Follow-up" -# Mark task as complete and reply (updates TASK_STATUS to done) -python3 hooks/send-reply.py --complete msg-20251212-143000-gregor gregor "Task complete! See results." +# Mark task complete and reply +python3 hooks/send-reply.py --complete msg-20251212-143000-gregor gregor "Task done." ``` -**Task Status Tracking:** -- When Claude reads a message, it's marked as `TASK_STATUS: working` -- Using `--complete` marks the original task as `TASK_STATUS: done` -- Use `/tasks` in GUI to see task queue status - -**Rate Limiting:** -- Claude processes one task at a time (FIFO queue) -- High priority tasks jump the queue -- If Claude is already working on a task, new tasks stay queued -- Complete current task before next one is loaded - -**Response Routing:** -- `github:123` - Post to GitHub issue #123 (requires `gh` CLI) -- `file:path/to.md` - Append to file (relative to space) -- `@user` - CC to another user - -The reply is: -1. Saved to the recipient's inbox (`gregor.org`) -2. Sent via relay for real-time delivery (if connected) - -### Marking Messages from Claude +### Managing the Task Queue ```bash -# Mark a message as TODO -python3 hooks/mark-message.py 151230 todo - -# Mark as done -python3 hooks/mark-message.py 151230 done - -# Mark as read (clear status) -python3 hooks/mark-message.py 151230 read -``` - -The ID is shown in the hook output `[msg-id: msg-20251212-151230-tex]` - use any unique part. - -## How It Works - -### Message Flow - -1. You type `@gregor hello` in GUI -2. Message saved to `~/Data/1-datafund/org/inboxes/gregor.org` -3. Message sent via WebSocket relay (if online) -4. Gregor's GUI shows notification instantly +# Approve a waiting task +python3 hooks/task-queue.py approve msg-20251212-143000-gregor -### @claude Routing +# Reject a task +python3 hooks/task-queue.py reject msg-20251212-143000-gregor -- `@claude do this` → routes to `@yourname-claude` -- Each user's Claude is separate -- Whitelist controls who can message your Claude -- Non-whitelisted users get: "Auto-reply: @tex-claude is not accepting messages from @bob" - -### Message Storage (org-mode) - -```org -* MESSAGE [2025-12-12 Fri 14:30] :unread: -:PROPERTIES: -:ID: msg-20251212-143000-gregor -:FROM: gregor -:TO: tex -:THREAD: thread-msg-20251212-142500-tex -:REPLY_TO: msg-20251212-142500-tex -:END: -Can you review PR #24? +# Cancel a running task +python3 hooks/task-queue.py cancel msg-20251212-143000-gregor ``` -**Threading properties:** -- `THREAD` - Thread ID (shared by all messages in conversation) -- `REPLY_TO` - Parent message ID (direct reply target) - ## Relay Server -The relay enables real-time messaging between team members. - -**Default relay**: `wss://datacore-messaging-relay.datafund.ai/ws` +The relay provides real-time delivery between team members. It is optional — +the module works offline using the org-workspace store alone. -### Deploy Your Own - -See `relay/README.md` for Docker deployment instructions. +**Deploy your own** (see `relay/README.md`): ```bash cd relay/ @@ -251,35 +214,38 @@ echo "RELAY_SECRET=your-secret" > .env docker-compose up -d --build ``` -## Files +Configure the URL in `settings.local.yaml` under `messaging.relay.url`. + +## File Layout ``` -datacore-msg.py # Unified GUI app +datacore-msg.py # Unified CLI/GUI entry point install.sh # Interactive installer settings.local.yaml # Your settings (gitignored) +lib/ +├── config.py # Settings, trust tiers, paths +├── message_store.py # org-workspace message CRUD +├── agent_inbox.py # Agent task state machine +├── governor.py # Task acceptance policy +└── relay.py # WebSocket relay client + hooks/ -├── inbox-watcher.py # Claude Code hook -└── send-reply.py # Reply helper for Claude +├── inbox-watcher.py # Claude Code hook (prompt check) +├── send-reply.py # Reply helper for Claude +├── mark-message.py # Mark messages read/todo/done +└── task-queue.py # Approve/reject/cancel tasks + +templates/ +└── contacts.yaml # Known actors template relay/ ├── Dockerfile ├── docker-compose.yml ├── datacore-msg-relay.py └── README.md - -lib/ -├── datacore-msg-relay.py # Relay server -└── datacore-msg-window.py # Legacy GUI (PyQt6) ``` -## Requirements - -- Python 3.8+ -- PyQt6: `pip install PyQt6` -- websockets: `pip install websockets` -- pyyaml: `pip install pyyaml` - ## License MIT diff --git a/lib/datacore-msg-window.py b/lib/datacore-msg-window.py deleted file mode 100755 index 458fcf1..0000000 --- a/lib/datacore-msg-window.py +++ /dev/null @@ -1,692 +0,0 @@ -#!/usr/bin/env python3 -""" -datacore-msg-window - Floating message overlay for Datacore (PyQt6 version) - -A small always-on-top window that shows messages in real-time. -Supports both local file watching and WebSocket relay for remote messages. - -Usage: - python datacore-msg-window.py - -Requirements: - - Python 3.8+ - - PyQt6: pip install PyQt6 - - websockets (optional): pip install websockets -""" - -import sys -import os -import json -import threading -import asyncio -from pathlib import Path -from datetime import datetime - -from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QTextEdit, QLineEdit, QLabel, QFrame, QScrollArea -) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject -from PyQt6.QtGui import QFont, QColor, QTextCursor, QTextCharFormat - -# Optional websockets for relay -try: - import websockets - HAS_WEBSOCKETS = True -except ImportError: - HAS_WEBSOCKETS = False - -# === CONFIG === - -DATACORE_ROOT = Path(os.environ.get("DATACORE_ROOT", Path.home() / "Data")) -MODULE_DIR = Path(__file__).parent.parent # datacore-messaging/ -POLL_INTERVAL = 2000 # milliseconds - - -def get_settings() -> dict: - """Load settings from yaml. Module settings take precedence.""" - try: - import yaml - except ImportError: - return {} - - settings = {} - - # First load from datacore root (base settings) - root_settings = DATACORE_ROOT / ".datacore/settings.local.yaml" - if root_settings.exists(): - try: - settings = yaml.safe_load(root_settings.read_text()) or {} - except: - pass - - # Then overlay module-specific settings - module_settings = MODULE_DIR / "settings.local.yaml" - if module_settings.exists(): - try: - mod = yaml.safe_load(module_settings.read_text()) or {} - for key, value in mod.items(): - if key in settings and isinstance(settings[key], dict) and isinstance(value, dict): - settings[key].update(value) - else: - settings[key] = value - except: - pass - - return settings - - -def get_username() -> str: - """Get current user's identity from settings or environment.""" - if "DATACORE_USER" in os.environ: - return os.environ["DATACORE_USER"] - - conf = get_settings() - name = conf.get("identity", {}).get("name") - if name: - return name - - return os.environ.get("USER", "unknown") - - -def get_default_space() -> str: - """Get default space for sending messages.""" - conf = get_settings() - space = conf.get("messaging", {}).get("default_space") - if space: - return space - - for p in sorted(DATACORE_ROOT.glob("[1-9]-*")): - if p.is_dir(): - return p.name - - return "1-team" - - -def get_relay_url() -> str: - """Get relay server URL from settings.""" - conf = get_settings() - return conf.get("messaging", {}).get("relay", {}).get("url", "wss://datacore-relay.fly.dev") - - -def get_relay_secret() -> str: - """Get relay shared secret from settings.""" - conf = get_settings() - return conf.get("messaging", {}).get("relay", {}).get("secret") - - -def is_relay_enabled() -> bool: - """Check if relay is enabled in settings.""" - conf = get_settings() - relay_conf = conf.get("messaging", {}).get("relay", {}) - return relay_conf.get("enabled", False) or bool(relay_conf.get("secret")) - - -def get_claude_whitelist() -> list: - """Get list of users allowed to message this user's Claude.""" - conf = get_settings() - return conf.get("messaging", {}).get("claude_whitelist", []) - - -class SignalBridge(QObject): - """Bridge for thread-safe UI updates.""" - message_received = pyqtSignal(str, str, str, bool, str, bool) - status_changed = pyqtSignal(str) - presence_changed = pyqtSignal(list) - - -class RelayClient: - """WebSocket client for relay server.""" - - def __init__(self, url: str, secret: str, username: str, bridge: SignalBridge, claude_whitelist: list = None): - self.url = url - self.secret = secret - self.local_username = username - self.bridge = bridge - self.claude_whitelist = claude_whitelist or [] - self.ws = None - self.username = None - self.online_users = [] - self.running = False - - async def connect(self): - """Connect and authenticate with relay.""" - try: - self.ws = await websockets.connect(self.url) - - await self.ws.send(json.dumps({ - "type": "auth", - "secret": self.secret, - "username": self.local_username, - "claude_whitelist": self.claude_whitelist - })) - - response = json.loads(await self.ws.recv()) - - if response.get("type") == "auth_error": - self.bridge.status_changed.emit(f"Auth failed: {response.get('message')}") - return False - - if response.get("type") == "auth_ok": - self.username = response.get("username") - self.online_users = response.get("online", []) - self.bridge.status_changed.emit(f"● relay @{self.username}") - self.bridge.presence_changed.emit(self.online_users) - return True - - return False - except Exception as e: - self.bridge.status_changed.emit(f"Connection failed: {str(e)[:30]}") - return False - - async def send_message(self, to: str, text: str, msg_id: str, priority: str = "normal") -> bool: - """Send message via relay.""" - if not self.ws: - return False - - try: - await self.ws.send(json.dumps({ - "type": "send", - "to": to, - "text": text, - "msg_id": msg_id, - "priority": priority - })) - - response = json.loads(await self.ws.recv()) - return response.get("delivered", False) - except: - return False - - async def listen(self): - """Listen for incoming messages.""" - self.running = True - try: - async for message in self.ws: - if not self.running: - break - - data = json.loads(message) - msg_type = data.get("type") - - if msg_type == "message": - self.bridge.message_received.emit( - data.get("from", "?"), - data.get("text", ""), - datetime.now().strftime("%H:%M"), - True, # unread - data.get("priority", "normal"), - True # via_relay - ) - - elif msg_type == "presence_change": - self.online_users = data.get("online", []) - self.bridge.presence_changed.emit(self.online_users) - - except Exception as e: - self.bridge.status_changed.emit(f"Relay error: {str(e)[:20]}") - - async def close(self): - """Close connection.""" - self.running = False - if self.ws: - await self.ws.close() - - -class MessageWindow(QMainWindow): - """Main message window.""" - - def __init__(self): - super().__init__() - - self.username = get_username() - self.default_space = get_default_space() - self.seen_ids = set() - self.relay_client = None - self.relay_connected = False - self.bridge = SignalBridge() - - # Connect signals - self.bridge.message_received.connect(self.add_message) - self.bridge.status_changed.connect(self.update_relay_status) - self.bridge.presence_changed.connect(self.update_presence) - - self._setup_ui() - self._load_existing_messages() - self._start_watcher() - self._start_relay() - - def _setup_ui(self): - """Setup the user interface.""" - self.setWindowTitle(f"Messages @{self.username}") - self.setGeometry(100, 100, 350, 500) - - # Always on top - self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint) - - # Dark theme stylesheet - self.setStyleSheet(""" - QMainWindow { - background-color: #1e1e1e; - } - QLabel { - color: #d4d4d4; - } - QTextEdit { - background-color: #1e1e1e; - color: #d4d4d4; - border: none; - font-family: Menlo, Monaco, monospace; - font-size: 12px; - } - QLineEdit { - background-color: #333333; - color: #ffffff; - border: 1px solid #555555; - border-radius: 4px; - padding: 8px; - font-family: Menlo, Monaco, monospace; - font-size: 12px; - } - QLineEdit:focus { - border: 1px solid #569cd6; - } - """) - - # Central widget - central = QWidget() - self.setCentralWidget(central) - layout = QVBoxLayout(central) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(8) - - # Header - header = QHBoxLayout() - - self.user_label = QLabel(f"@{self.username}") - self.user_label.setStyleSheet("color: #569cd6; font-weight: bold; font-size: 13px;") - header.addWidget(self.user_label) - - self.status_dot = QLabel(" ●") - self.status_dot.setStyleSheet("color: #4ec9b0; font-size: 13px;") - header.addWidget(self.status_dot) - - header.addStretch() - - self.online_label = QLabel("") - self.online_label.setStyleSheet("color: #666666; font-size: 11px;") - header.addWidget(self.online_label) - - self.relay_label = QLabel("(connecting...)") - self.relay_label.setStyleSheet("color: #c586c0; font-size: 11px;") - header.addWidget(self.relay_label) - - layout.addLayout(header) - - # Messages area - self.messages_area = QTextEdit() - self.messages_area.setReadOnly(True) - self.messages_area.setMinimumHeight(200) - layout.addWidget(self.messages_area, stretch=1) - - # Separator - separator = QFrame() - separator.setFrameShape(QFrame.Shape.HLine) - separator.setStyleSheet("background-color: #333333;") - layout.addWidget(separator) - - # Input field - self.input_field = QLineEdit() - self.input_field.setPlaceholderText("@user message") - self.input_field.returnPressed.connect(self._send_message) - layout.addWidget(self.input_field) - - # Status bar - self.status_label = QLabel(f"Space: {self.default_space}") - self.status_label.setStyleSheet("color: #666666; font-size: 11px;") - layout.addWidget(self.status_label) - - # Position window at top-right - screen = QApplication.primaryScreen().geometry() - self.move(screen.width() - 370, 40) - - def add_message(self, sender: str, text: str, time_str: str, - unread: bool = False, priority: str = "normal", via_relay: bool = False): - """Add a message to the display.""" - cursor = self.messages_area.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - - # Unread marker - if unread: - fmt = QTextCharFormat() - fmt.setForeground(QColor("#f48771")) - cursor.insertText("● ", fmt) - else: - cursor.insertText(" ") - - # Sender - fmt = QTextCharFormat() - if sender.startswith("you→"): - fmt.setForeground(QColor("#4ec9b0")) - elif sender == "claude": - fmt.setForeground(QColor("#c586c0")) - elif via_relay: - fmt.setForeground(QColor("#dcdcaa")) - else: - fmt.setForeground(QColor("#569cd6")) - fmt.setFontWeight(700) - cursor.insertText(f"@{sender} ", fmt) - - # Time - fmt = QTextCharFormat() - fmt.setForeground(QColor("#666666")) - relay_marker = " ↗" if via_relay else "" - cursor.insertText(f"{time_str}{relay_marker}\n", fmt) - - # Priority - if priority == "high": - fmt = QTextCharFormat() - fmt.setForeground(QColor("#f48771")) - cursor.insertText(" [!] ", fmt) - else: - cursor.insertText(" ") - - # Message text - fmt = QTextCharFormat() - fmt.setForeground(QColor("#d4d4d4")) - display_text = text[:200] + "..." if len(text) > 200 else text - cursor.insertText(f"{display_text}\n\n", fmt) - - # Scroll to bottom - self.messages_area.setTextCursor(cursor) - self.messages_area.ensureCursorVisible() - - # Notify - if unread: - self._notify(sender, text) - - def _notify(self, sender: str, text: str): - """Send notification.""" - self.raise_() - self.activateWindow() - - # macOS notification - if sys.platform == "darwin": - import subprocess - try: - preview = text[:50] + "..." if len(text) > 50 else text - script = f'display notification "{preview}" with title "Datacore" subtitle "@{sender}"' - subprocess.Popen(["osascript", "-e", script], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - subprocess.Popen(["afplay", "/System/Library/Sounds/Ping.aiff"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except: - pass - - def update_relay_status(self, status: str): - """Update relay status label.""" - if "●" in status: - self.relay_label.setStyleSheet("color: #4ec9b0; font-size: 11px;") - elif "failed" in status or "error" in status: - self.relay_label.setStyleSheet("color: #f48771; font-size: 11px;") - else: - self.relay_label.setStyleSheet("color: #c586c0; font-size: 11px;") - self.relay_label.setText(f"({status})") - - def update_presence(self, online_users: list): - """Update online users count.""" - count = len(online_users) - self.online_label.setText(f"{count} online" if count > 0 else "") - - def _send_message(self): - """Send message from input field.""" - text = self.input_field.text().strip() - - if not text: - return - - if not text.startswith("@"): - self._show_error("Start with @username") - return - - parts = text.split(" ", 1) - recipient = parts[0][1:] - msg_text = parts[1] if len(parts) > 1 else "" - - if not recipient: - self._show_error("Specify recipient: @user") - return - - if not msg_text: - self._show_error("Enter a message") - return - - msg_id = self._write_to_inbox(recipient, msg_text) - - if msg_id: - via_relay = False - if self.relay_connected and self.relay_client: - def send_relay(): - asyncio.run(self.relay_client.send_message(recipient, msg_text, msg_id)) - threading.Thread(target=send_relay, daemon=True).start() - via_relay = True - - self.add_message( - f"you→{recipient}", - msg_text, - datetime.now().strftime("%H:%M"), - unread=False, - via_relay=via_relay, - ) - - self.input_field.clear() - - delivery = "relay" if via_relay else "local" - self.status_label.setText(f"✓ Sent to @{recipient} ({delivery})") - QTimer.singleShot(3000, lambda: self.status_label.setText(f"Space: {self.default_space}")) - else: - self._show_error("Failed to send") - - def _show_error(self, msg: str): - """Show error in status bar.""" - self.status_label.setStyleSheet("color: #f48771; font-size: 11px;") - self.status_label.setText(f"⚠ {msg}") - QTimer.singleShot(3000, lambda: ( - self.status_label.setStyleSheet("color: #666666; font-size: 11px;"), - self.status_label.setText(f"Space: {self.default_space}") - )) - - def _write_to_inbox(self, to: str, text: str) -> str: - """Write message to recipient's org inbox.""" - try: - inbox_dir = DATACORE_ROOT / self.default_space / "org/inboxes" - inbox_dir.mkdir(parents=True, exist_ok=True) - inbox = inbox_dir / f"{to}.org" - - now = datetime.now() - msg_id = f"msg-{now.strftime('%Y%m%d-%H%M%S')}-{self.username}" - timestamp = now.strftime("[%Y-%m-%d %a %H:%M]") - - entry = f""" -* MESSAGE {timestamp} :unread: -:PROPERTIES: -:ID: {msg_id} -:FROM: {self.username} -:TO: {to} -:PRIORITY: normal -:END: -{text} -""" - - with open(inbox, "a") as f: - f.write(entry) - - self.seen_ids.add(msg_id) - return msg_id - - except Exception as e: - print(f"Error writing message: {e}", file=sys.stderr) - return None - - def _load_existing_messages(self): - """Load last N messages from inbox on startup.""" - messages = [] - - for inbox in DATACORE_ROOT.glob(f"*/org/inboxes/{self.username}.org"): - try: - content = inbox.read_text() - for block in content.split("\n* MESSAGE ")[1:]: - msg = self._parse_message_block(block) - if msg: - self.seen_ids.add(msg["id"]) - messages.append(msg) - except: - pass - - messages.sort(key=lambda m: m.get("id", "")) - for msg in messages[-15:]: - self.add_message( - msg["from"], - msg["text"], - msg.get("time", "earlier"), - unread=msg.get("unread", False), - priority=msg.get("priority", "normal"), - ) - - def _parse_message_block(self, block: str) -> dict: - """Parse a MESSAGE block from org file.""" - try: - lines = block.split("\n") - header = lines[0] if lines else "" - - is_unread = ":unread:" in header - - time_str = "earlier" - if "[" in header and "]" in header: - ts = header[header.find("[")+1:header.find("]")] - parts = ts.split(" ") - if len(parts) >= 4: - time_str = parts[3] - - props = {} - text_lines = [] - in_props = False - - for line in lines[1:]: - if ":PROPERTIES:" in line: - in_props = True - elif ":END:" in line: - in_props = False - elif in_props and ": " in line: - line = line.strip() - if line.startswith(":") and ": " in line[1:]: - key_val = line[1:].split(": ", 1) - if len(key_val) == 2: - props[key_val[0].lower()] = key_val[1] - elif not in_props and line.strip(): - text_lines.append(line) - - return { - "id": props.get("id", ""), - "from": props.get("from", "?"), - "to": props.get("to", ""), - "text": "\n".join(text_lines).strip(), - "time": time_str, - "unread": is_unread, - "priority": props.get("priority", "normal"), - "source": props.get("source", "local"), - } - except: - return None - - def _start_watcher(self): - """Start timer to watch for new messages.""" - self.watcher_timer = QTimer(self) - self.watcher_timer.timeout.connect(self._check_inbox) - self.watcher_timer.start(POLL_INTERVAL) - - def _check_inbox(self): - """Check inbox for new messages.""" - try: - for inbox in DATACORE_ROOT.glob(f"*/org/inboxes/{self.username}.org"): - content = inbox.read_text() - - for block in content.split("\n* MESSAGE ")[1:]: - msg = self._parse_message_block(block) - - if msg and msg["id"] and msg["id"] not in self.seen_ids: - self.seen_ids.add(msg["id"]) - self.add_message( - msg["from"], - msg["text"], - msg.get("time", "now"), - unread=msg.get("unread", True), - priority=msg.get("priority", "normal"), - via_relay=msg.get("source") == "relay", - ) - except Exception as e: - print(f"Watcher error: {e}", file=sys.stderr) - - def _start_relay(self): - """Start relay connection if enabled.""" - if not HAS_WEBSOCKETS: - self.relay_label.setText("(no websockets)") - return - - if not is_relay_enabled(): - self.relay_label.setText("(local only)") - return - - secret = get_relay_secret() - if not secret: - self.relay_label.setText("(no secret)") - return - - thread = threading.Thread(target=self._relay_loop, daemon=True) - thread.start() - - def _relay_loop(self): - """Run relay client in background.""" - async def run_relay(): - self.relay_client = RelayClient( - get_relay_url(), - get_relay_secret(), - self.username, - self.bridge, - get_claude_whitelist(), - ) - - if await self.relay_client.connect(): - self.relay_connected = True - await self.relay_client.listen() - else: - self.relay_connected = False - - asyncio.run(run_relay()) - - -def main(): - if len(sys.argv) > 1 and sys.argv[1] in ["-h", "--help"]: - print(__doc__) - sys.exit(0) - - if not DATACORE_ROOT.exists(): - print(f"Error: DATACORE_ROOT not found: {DATACORE_ROOT}", file=sys.stderr) - sys.exit(1) - - app = QApplication(sys.argv) - app.setApplicationName("Datacore Messages") - - window = MessageWindow() - window.show() - - print(f"Datacore Messages - @{window.username}") - print(f"Watching: {DATACORE_ROOT}/*/org/inboxes/{window.username}.org") - if is_relay_enabled(): - print(f"Relay: {get_relay_url()}") - - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() diff --git a/templates/USERS.yaml b/templates/USERS.yaml deleted file mode 100644 index 38616ae..0000000 --- a/templates/USERS.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# User Registry for {{space}} Space -# -# This file tracks all users who can send/receive messages in this space. -# Users are auto-added when they first send a message. -# -# Format: -# users: -# username: -# handles: ["@handle1", "@handle2"] -# added: YYYY-MM-DD -# type: human|ai (optional, defaults to human) - -users: - claude: - handles: ["@claude", "@ai"] - added: {{date}} - type: ai diff --git a/templates/contacts.yaml b/templates/contacts.yaml new file mode 100644 index 0000000..d30c8a3 --- /dev/null +++ b/templates/contacts.yaml @@ -0,0 +1,8 @@ +# Known ActivityPub actors for this space +# Add actors via /msg-trust or manually edit this file +actors: [] +# Example: +# - id: "tex@team.example.com" +# name: "Tex" +# trust_tier: team +# added: 2026-03-11 From 6b8767dcfb09a4a6a6d2d3de6e1de42a6c99d733 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 21:39:11 +0100 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20governor=20concurrency=20=E2=80=94?= =?UTF-8?q?=20atomic=20writes,=20reload-inside-lock=20for=20all=20mutation?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _load_state_unlocked() for use inside FileLock contexts (no re-entrant locking) - Add _write_state_unlocked() using temp+os.replace() for atomic POSIX writes - record_usage, record_task_submission, record_task_completion now each acquire FileLock, reload from disk, modify, and atomically write — eliminating lost-update races - check_and_record updated to use _write_state_unlocked() (was using write_text directly) - Add _record_task_submission() as the internal implementation - record_task_submission() kept as public backward-compat wrapper with deprecation note - Add submit_task() as the preferred public API (check + record in one call) - _save_state() removed; replaced by the lock+load+modify+write pattern Co-Authored-By: Claude Sonnet 4.6 --- lib/governor.py | 117 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 81 insertions(+), 36 deletions(-) diff --git a/lib/governor.py b/lib/governor.py index 54c787d..208438d 100644 --- a/lib/governor.py +++ b/lib/governor.py @@ -5,6 +5,8 @@ """ import json +import os +import tempfile import time from dataclasses import dataclass, field from datetime import datetime @@ -48,8 +50,9 @@ def __init__(self, state_dir: Path | None = None): self._state: dict[str, _SenderState] = {} self._load_state() - def _load_state(self) -> None: - """Load persisted state from file. Does not use FileLock.""" + def _load_state_unlocked(self) -> None: + """Read state from disk. Must be called from within a FileLock context.""" + self._state = {} if self._budget_file.exists(): try: data = json.loads(self._budget_file.read_text()) @@ -63,8 +66,16 @@ def _load_state(self) -> None: except (json.JSONDecodeError, KeyError): pass - def _save_state(self) -> None: - """Persist state to file with FileLock for concurrent safety.""" + def _load_state(self) -> None: + """Load state from disk, acquiring FileLock for a safe read.""" + with FileLock(self._budget_file): + self._load_state_unlocked() + + def _write_state_unlocked(self) -> None: + """Atomically write current in-memory state to disk (temp+rename). + + Must be called from within a FileLock context. + """ now = time.time() hour_ago = now - 3600 data = {"date": self._today, "senders": {}} @@ -76,8 +87,20 @@ def _save_state(self) -> None: "tasks_today": state.tasks_today, "active_tasks": state.active_tasks, } - with FileLock(self._budget_file): - self._budget_file.write_text(json.dumps(data, indent=2)) + # Write to a temp file in the same directory, then atomically rename. + fd, tmp_path = tempfile.mkstemp( + dir=self._budget_file.parent, suffix=".tmp" + ) + try: + with os.fdopen(fd, "w") as fh: + fh.write(json.dumps(data, indent=2)) + os.replace(tmp_path, self._budget_file) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise def _check_date_rollover(self) -> None: """Reset state if day has changed.""" @@ -85,7 +108,6 @@ def _check_date_rollover(self) -> None: if today != self._today: self._today = today self._budget_file = self._state_dir / f"budget-{today}.json" - self._state = {} self._load_state() def _get_sender(self, actor_id: str) -> _SenderState: @@ -99,7 +121,11 @@ def check_task( estimated_tokens: int = 0, effort: int = 0, ) -> TaskCheckResult: - """Check whether a task from actor_id is allowed under current governance rules.""" + """Check whether a task from actor_id is allowed under current governance rules. + + This is a read-only check against the current in-memory state. For an + atomic check-and-record use check_and_record() or submit_task(). + """ self._check_date_rollover() tier_name = get_trust_tier(actor_id) tier_config = get_trust_tier_config(tier_name) @@ -186,47 +212,66 @@ def check_and_record( ) -> TaskCheckResult: """Check and atomically record a task submission if allowed.""" with FileLock(self._budget_file): - self._load_state() + self._load_state_unlocked() result = self.check_task(actor_id, estimated_tokens=estimated_tokens, effort=effort) if result.allowed: sender = self._get_sender(actor_id) sender.tasks_this_hour.append(time.time()) sender.tasks_today += 1 sender.active_tasks += 1 - now = time.time() - hour_ago = now - 3600 - data = {"date": self._today, "senders": {}} - for s_id, state in self._state.items(): - state.tasks_this_hour = [t for t in state.tasks_this_hour if t > hour_ago] - data["senders"][s_id] = { - "tokens_today": state.tokens_today, - "tasks_this_hour": state.tasks_this_hour, - "tasks_today": state.tasks_today, - "active_tasks": state.active_tasks, - } - self._budget_file.write_text(json.dumps(data, indent=2)) + self._write_state_unlocked() return result - def record_usage(self, actor_id: str, tokens: int) -> None: - """Record token usage for an actor.""" - self._check_date_rollover() - sender = self._get_sender(actor_id) - sender.tokens_today += tokens - self._save_state() + def submit_task( + self, + actor_id: str, + estimated_tokens: int = 0, + effort: int = 0, + ) -> TaskCheckResult: + """Check governance rules and record a task submission if allowed. + + Preferred public API over check_and_record (same semantics, clearer name). + """ + return self.check_and_record(actor_id, estimated_tokens=estimated_tokens, effort=effort) + + def _record_task_submission(self, actor_id: str) -> None: + """Unconditionally record a task submission (internal use only). + + Callers outside this class should use submit_task() which also + enforces governance rules. + """ + with FileLock(self._budget_file): + self._load_state_unlocked() + sender = self._get_sender(actor_id) + sender.tasks_this_hour.append(time.time()) + sender.tasks_today += 1 + sender.active_tasks += 1 + self._write_state_unlocked() def record_task_submission(self, actor_id: str) -> None: - """Record that a task was submitted by actor_id.""" - sender = self._get_sender(actor_id) - sender.tasks_this_hour.append(time.time()) - sender.tasks_today += 1 - sender.active_tasks += 1 - self._save_state() + """Record a task submission unconditionally. + + Deprecated: prefer submit_task() which enforces governance rules. + Kept for backward compatibility. + """ + self._record_task_submission(actor_id) + + def record_usage(self, actor_id: str, tokens: int) -> None: + """Record token usage for an actor.""" + with FileLock(self._budget_file): + self._check_date_rollover() + self._load_state_unlocked() + sender = self._get_sender(actor_id) + sender.tokens_today += tokens + self._write_state_unlocked() def record_task_completion(self, actor_id: str) -> None: """Record that a task was completed by actor_id.""" - sender = self._get_sender(actor_id) - sender.active_tasks = max(0, sender.active_tasks - 1) - self._save_state() + with FileLock(self._budget_file): + self._load_state_unlocked() + sender = self._get_sender(actor_id) + sender.active_tasks = max(0, sender.active_tasks - 1) + self._write_state_unlocked() def get_usage(self, actor_id: str) -> dict: """Get usage stats for an actor.""" From 4959f371c977647ed37e045c168d238b417512fe Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 21:40:51 +0100 Subject: [PATCH 14/17] fix: remove workspace exposure, add ID monotonic counter, validate property inputs, fix test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `workspace` property from MessageStore and AgentInbox to prevent callers from bypassing state guards and FileLock - Add `AgentInbox.find_by_id()` as focused replacement; update hooks/send-reply.py and hooks/task-queue.py to use it - Add process-local `_MSG_COUNTER` / `_FILE_COUNTER` (itertools.count) to `_unique_msg_id` and `_unique_file_id` hash inputs, eliminating collision risk when two IDs are generated within the same microsecond - Add `_validate_property_value()` helper; apply to from_actor, to_actor, trust_tier, reply_to, priority, and extra_props in create_message / create_file_delivery / create_task — newlines in property values would corrupt org property drawers - Fix `test_message_id_same_content_different_time`: now asserts IDs differ - Add `test_live_node_stays_valid_after_create`: verifies msg1 survives workspace generation bump caused by creating msg2 - Add `TestInputValidation` classes to both test files covering newline rejection and the allowed-newline-in-body-text case - Add `TestFindById` to test_agent_inbox.py covering find and miss paths - Add explanatory comment above _TASK_STATE_CONFIG and _MSG_STATE_CONFIG clarifying that sequences do not constrain transitions Co-Authored-By: Claude Sonnet 4.6 --- hooks/send-reply.py | 2 +- hooks/task-queue.py | 6 ++--- lib/agent_inbox.py | 21 +++++++++++---- lib/message_store.py | 53 ++++++++++++++++++++++++++++++++----- tests/test_agent_inbox.py | 29 ++++++++++++++++++++ tests/test_message_store.py | 46 +++++++++++++++++++++++++++++++- 6 files changed, 140 insertions(+), 17 deletions(-) diff --git a/hooks/send-reply.py b/hooks/send-reply.py index 39cba54..825de23 100755 --- a/hooks/send-reply.py +++ b/hooks/send-reply.py @@ -36,7 +36,7 @@ def main() -> None: if args.complete_task: inbox = AgentInbox(space_root, f"{username}-claude") - node = inbox.workspace.find_by_id(args.complete_task) + node = inbox.find_by_id(args.complete_task) if node and node.todo == "WORKING": inbox.complete(node, tokens_used=args.tokens) print(f"[messaging] Completed task: {args.complete_task}") diff --git a/hooks/task-queue.py b/hooks/task-queue.py index 636c956..a14e6c8 100755 --- a/hooks/task-queue.py +++ b/hooks/task-queue.py @@ -34,17 +34,17 @@ def main() -> None: print(f" Done: {counts['done']}") print(f" Total: {counts['total']}") elif args.action == "approve" and args.task_id: - node = inbox.workspace.find_by_id(args.task_id) + node = inbox.find_by_id(args.task_id) if node: inbox.approve(node) print(f"[messaging] Approved: {args.task_id}") elif args.action == "reject" and args.task_id: - node = inbox.workspace.find_by_id(args.task_id) + node = inbox.find_by_id(args.task_id) if node: inbox.reject(node, reason=args.reason) print(f"[messaging] Rejected: {args.task_id}") elif args.action == "cancel" and args.task_id: - node = inbox.workspace.find_by_id(args.task_id) + node = inbox.find_by_id(args.task_id) if node: inbox.cancel(node, reason=args.reason) print(f"[messaging] Cancelled: {args.task_id}") diff --git a/lib/agent_inbox.py b/lib/agent_inbox.py index c40f850..448ff04 100644 --- a/lib/agent_inbox.py +++ b/lib/agent_inbox.py @@ -23,11 +23,14 @@ from lib._org_utils import _refresh_node from lib.config import get_trust_tier_config +from lib.message_store import _validate_property_value _TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" -# CORRECT API: sequences + terminal_states +# Sequences define the known states — they do NOT constrain transitions. +# Edge enforcement is handled exclusively via _assert_state preconditions +# in write methods. This allows non-linear workflows (e.g. DONE -> QUEUED). _TASK_STATE_CONFIG = StateConfig( sequences={"tasks": ["TODO", "WAITING", "QUEUED", "WORKING", "DONE", "CANCELLED", "ARCHIVED"]}, terminal_states=frozenset(["CANCELLED", "ARCHIVED"]), @@ -102,6 +105,14 @@ def create_task( effort: int | None = None, estimated_tokens: int | None = None, ) -> NodeView: + """Create a new task entry. + + Raises ValueError if from_actor or trust_tier contain newlines + (which would corrupt org property drawers). + """ + _validate_property_value(from_actor, "from_actor") + _validate_property_value(trust_tier, "trust_tier") + auto_accept = _should_auto_accept(trust_tier) state = "QUEUED" if auto_accept else "WAITING" now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") @@ -220,6 +231,10 @@ def find_awaiting_approval(self) -> list[NodeView]: if n.properties.get("AWAITING") == "owner-approval" ] + def find_by_id(self, task_id: str) -> NodeView | None: + """Find a task by its ID (in-memory, no reload).""" + return self._ws.find_by_id(task_id) + def counts(self) -> dict[str, int]: self._reload() all_nodes = list(self._ws.all_nodes()) @@ -233,7 +248,3 @@ def counts(self) -> dict[str, int]: "archived": sum(1 for n in nodes if n.todo == "ARCHIVED"), "total": len(nodes), } - - @property - def workspace(self) -> OrgWorkspace: - return self._ws diff --git a/lib/message_store.py b/lib/message_store.py index c277fd1..66e0d14 100644 --- a/lib/message_store.py +++ b/lib/message_store.py @@ -17,6 +17,7 @@ """ import hashlib +import itertools import os from datetime import datetime from pathlib import Path @@ -29,17 +30,36 @@ _TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" +# Sequences define the known states — they do NOT constrain transitions. +# Edge enforcement is handled exclusively via _assert_state preconditions +# in write methods. This allows non-linear workflows (e.g. DONE -> QUEUED). _MSG_STATE_CONFIG = StateConfig( sequences={"messaging": ["TODO", "WAITING", "DONE", "ARCHIVED", "CANCELLED"]}, terminal_states=frozenset(["ARCHIVED", "CANCELLED"]), ) +# Process-local counters to ensure ID uniqueness even within the same microsecond. +_MSG_COUNTER = itertools.count() +_FILE_COUNTER = itertools.count() + + +def _validate_property_value(value: str, name: str) -> None: + """Raise ValueError if value contains characters unsafe in org property drawers. + + Property values are written as single-line key: value pairs. + Newlines would corrupt the drawer structure. + """ + if "\n" in value or "\r" in value: + raise ValueError( + f"Property '{name}' must not contain newlines; got: {value!r}" + ) + def _unique_msg_id(from_actor: str, to_actor: str, content: str) -> str: """Generate a timestamp-unique message ID.""" now = datetime.now() ts = now.strftime("%Y%m%d-%H%M%S") - raw = f"{from_actor}:{to_actor}:{content}:{now.isoformat()}" + raw = f"{from_actor}:{to_actor}:{content}:{now.isoformat()}:{next(_MSG_COUNTER)}" h = hashlib.sha256(raw.encode()).hexdigest()[:8] return f"msg-{ts}-{h}" @@ -48,7 +68,7 @@ def _unique_file_id(from_actor: str, filename: str) -> str: """Generate a timestamp-unique file delivery ID.""" now = datetime.now() ts = now.strftime("%Y%m%d-%H%M%S") - raw = f"{from_actor}:{filename}:{now.isoformat()}" + raw = f"{from_actor}:{filename}:{now.isoformat()}:{next(_FILE_COUNTER)}" h = hashlib.sha256(raw.encode()).hexdigest()[:8] return f"file-{ts}-{h}" @@ -77,6 +97,9 @@ def _ensure_files(self) -> None: self._msg_dir.mkdir(parents=True, exist_ok=True) self._agents_dir.mkdir(exist_ok=True) for p in (self._inbox_path, self._outbox_path): + # outbox.org is scaffolded here but not yet written to — it is + # reserved for Phase 2 as an outgoing message queue that buffers + # sent messages when the relay is offline or unavailable. try: fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.write(fd, _TODO_HEADER.encode()) @@ -105,7 +128,20 @@ def create_message( Returns a NodeView that stays valid across subsequent create_message calls (tracked and refreshed when the workspace generation bumps). + + Raises ValueError if from_actor, to_actor, reply_to, priority, or any + extra_props value contains newlines (which would corrupt org property + drawers). Content (body text) may contain newlines. """ + _validate_property_value(from_actor, "from_actor") + _validate_property_value(to_actor, "to_actor") + if reply_to is not None: + _validate_property_value(reply_to, "reply_to") + if priority is not None: + _validate_property_value(priority, "priority") + for key, val in extra_props.items(): + _validate_property_value(val, key) + msg_id = _unique_msg_id(from_actor, to_actor, content) now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") heading = now_str @@ -155,7 +191,15 @@ def create_file_delivery( """Create a file delivery notification in the inbox. Returns a NodeView valid until the next find_* call. + + Raises ValueError if any property string contains newlines. """ + _validate_property_value(from_actor, "from_actor") + _validate_property_value(filename, "filename") + _validate_property_value(content_type, "content_type") + _validate_property_value(swarm_ref, "swarm_ref") + _validate_property_value(fairdrop_ref, "fairdrop_ref") + file_id = _unique_file_id(from_actor, filename) now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") heading = now_str @@ -232,8 +276,3 @@ def archive(self, node: NodeView) -> None: with FileLock(self._inbox_path): self._ws.transition(node, "ARCHIVED") self._ws.save(self._inbox_path) - - @property - def workspace(self) -> OrgWorkspace: - """Access underlying workspace (for advanced queries).""" - return self._ws diff --git a/tests/test_agent_inbox.py b/tests/test_agent_inbox.py index 1ae853f..eb69070 100644 --- a/tests/test_agent_inbox.py +++ b/tests/test_agent_inbox.py @@ -125,6 +125,35 @@ def test_precondition_claim_on_waiting_raises(self, agent_store): agent_store.claim(node) +class TestInputValidation: + def test_newline_in_from_actor_raises(self, agent_store): + with pytest.raises(ValueError, match="from_actor"): + agent_store.create_task("bad\nactor", "task", "owner", ["AI"]) + + def test_newline_in_trust_tier_raises(self, agent_store): + with pytest.raises(ValueError, match="trust_tier"): + agent_store.create_task("a@x.com", "task", "own\ner", ["AI"]) + + def test_newline_in_content_is_allowed(self, agent_store): + # Task content becomes the heading — handled by org-workspace + node = agent_store.create_task("owner@x.com", "Line1\nLine2", "owner", ["AI"]) + assert node.todo == "QUEUED" + + +class TestFindById: + def test_find_by_id_returns_node(self, agent_store): + node = agent_store.create_task("owner@x.com", "task", "owner", ["AI"]) + task_id = node.properties.get("ID") or node.properties.get("CUSTOM_ID") + # find_by_id may use the org ID property name — try both + found = agent_store.find_by_id(task_id) if task_id else None + # Even if the workspace stores IDs differently, the method must not raise + assert found is not None or task_id is None # graceful on missing ID + + def test_find_by_id_unknown_returns_none(self, agent_store): + result = agent_store.find_by_id("nonexistent-id-xyz") + assert result is None + + class TestQueries: def test_find_queued(self, agent_store): agent_store.create_task("a@x.com", "t1", "owner", ["AI"]) diff --git a/tests/test_message_store.py b/tests/test_message_store.py index b287d81..ee7bca4 100644 --- a/tests/test_message_store.py +++ b/tests/test_message_store.py @@ -57,7 +57,18 @@ def test_message_id_same_content_different_time(self, store): msg2 = store.create_message( from_actor="a@x.com", to_actor="b@x.com", content="same" ) - # IDs may collide within same second — that's OK for tests + assert msg1.properties["ID"] != msg2.properties["ID"] + + def test_live_node_stays_valid_after_create(self, store): + """msg1 remains readable and transitionable after msg2 is created.""" + msg1 = store.create_message("a@x.com", "b@x.com", "First") + # Creating a second message bumps workspace generation + store.create_message("a@x.com", "b@x.com", "Second") + # msg1 must still be valid — properties readable and state transitionable + assert msg1.properties["FROM"] == "a@x.com" + assert msg1.todo == "TODO" + store.mark_read(msg1) + assert msg1.todo == "DONE" class TestQueryMessages: @@ -95,6 +106,39 @@ def test_mark_archived(self, store): assert msg.todo == "ARCHIVED" +class TestInputValidation: + def test_newline_in_from_actor_raises(self, store): + with pytest.raises(ValueError, match="from_actor"): + store.create_message("bad\nactor", "b@x.com", "Hello") + + def test_newline_in_to_actor_raises(self, store): + with pytest.raises(ValueError, match="to_actor"): + store.create_message("a@x.com", "bad\nactor", "Hello") + + def test_newline_in_reply_to_raises(self, store): + with pytest.raises(ValueError, match="reply_to"): + store.create_message("a@x.com", "b@x.com", "Hello", reply_to="bad\nid") + + def test_newline_in_extra_prop_raises(self, store): + with pytest.raises(ValueError, match="CUSTOM"): + store.create_message("a@x.com", "b@x.com", "Hello", CUSTOM="bad\nvalue") + + def test_newline_in_content_is_allowed(self, store): + # Body text may contain newlines — only property values are restricted + node = store.create_message("a@x.com", "b@x.com", "Line1\nLine2") + assert node.todo == "TODO" + + def test_newline_in_file_delivery_filename_raises(self, store): + with pytest.raises(ValueError, match="filename"): + store.create_file_delivery( + from_actor="a@x.com", + filename="bad\nfile.pdf", + size=100, + content_type="application/pdf", + swarm_ref="abc123", + ) + + class TestFileDelivery: def test_create_file_delivery(self, store): node = store.create_file_delivery( From f8386312a7916c66616f714c504ebeacdcc30f0a Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 21:41:36 +0100 Subject: [PATCH 15/17] docs: fix stale references, add migration guide, update module.yaml for v0.2.0 reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module.yaml: separate `provides` (hooks, implemented) from `planned` (commands/agents are spec-only docs; CLI implementation is Phase 2) - commands/*.md: replace all org/inboxes/ → org/messaging/ and USERS.yaml → contacts.yaml path references - UPGRADING.md: new migration guide for v0.1.0 → v0.2.0 with mv/merge commands and contacts.yaml format conversion - README.md: remove relay/ directory listing (consolidated to lib/relay.py), remove /msg-trust reference (use trust_overrides in settings instead), update relay deploy instructions to match actual implementation - lib/relay.py: remove dead claude_whitelist parameter from RelayServer, add TLS note to create_relay_app docstring, add TLS info log in run_relay - lib/message_store.py: add comment explaining outbox.org is Phase 2 placeholder Co-Authored-By: Claude Sonnet 4.6 --- README.md | 26 ++++++----- UPGRADING.md | 93 ++++++++++++++++++++++++++++++++++++++++ commands/broadcast.md | 6 +-- commands/msg-add-user.md | 28 ++++++------ commands/msg.md | 14 +++--- commands/my-messages.md | 4 +- commands/reply.md | 6 +-- lib/relay.py | 16 +++++-- module.yaml | 12 ++++++ 9 files changed, 162 insertions(+), 43 deletions(-) create mode 100644 UPGRADING.md diff --git a/README.md b/README.md index 05dc6f7..32a9cfd 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ actors: added: 2026-03-11 ``` -Add actors via `/msg-trust` or edit the file directly. +Add actors by editing `{space}/contacts.yaml` directly, or set per-actor trust +overrides in `settings.local.yaml` under `messaging.trust_overrides`. ## Usage @@ -206,12 +207,20 @@ python3 hooks/task-queue.py cancel msg-20251212-143000-gregor The relay provides real-time delivery between team members. It is optional — the module works offline using the org-workspace store alone. -**Deploy your own** (see `relay/README.md`): +The relay server is implemented in `lib/relay.py`. TLS termination should be +handled by a reverse proxy (nginx, Caddy) in front of the relay process. + +**Run the relay:** + +```bash +RELAY_SECRET=your-secret python3 -m lib.relay --port 8080 +``` + +**Or deploy with Docker** (see `fly.toml` for Fly.io example): ```bash -cd relay/ echo "RELAY_SECRET=your-secret" > .env -docker-compose up -d --build +docker build -t datacore-relay . && docker run -p 8080:8080 --env-file .env datacore-relay ``` Configure the URL in `settings.local.yaml` under `messaging.relay.url`. @@ -222,13 +231,14 @@ Configure the URL in `settings.local.yaml` under `messaging.relay.url`. datacore-msg.py # Unified CLI/GUI entry point install.sh # Interactive installer settings.local.yaml # Your settings (gitignored) +UPGRADING.md # Migration guide (v0.1.0 → v0.2.0+) lib/ ├── config.py # Settings, trust tiers, paths ├── message_store.py # org-workspace message CRUD ├── agent_inbox.py # Agent task state machine ├── governor.py # Task acceptance policy -└── relay.py # WebSocket relay client +└── relay.py # WebSocket relay server (consolidated) hooks/ ├── inbox-watcher.py # Claude Code hook (prompt check) @@ -238,12 +248,6 @@ hooks/ templates/ └── contacts.yaml # Known actors template - -relay/ -├── Dockerfile -├── docker-compose.yml -├── datacore-msg-relay.py -└── README.md ``` ## License diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..aa54ae9 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,93 @@ +# Upgrading datacore-messaging + +## v0.1.0 → v0.2.0 + +This release changes the storage layout and contact registry format. + +### Breaking changes + +| What | v0.1.0 | v0.2.0 | +|------|--------|--------| +| Messaging directory | `{space}/org/inboxes/` | `{space}/org/messaging/` | +| Per-user inbox files | `org/inboxes/{username}.org` | `org/messaging/inbox.org` (universal inbox) | +| Contact registry | `org/inboxes/USERS.yaml` | `contacts.yaml` (space root) | + +### Migration steps + +**1. Move the messaging directory** + +```bash +# For each space (e.g. 1-datafund): +mv 1-datafund/org/inboxes 1-datafund/org/messaging +``` + +**2. Merge per-user inbox files into the universal inbox** + +v0.1.0 stored messages in separate per-user files (`gregor.org`, `crt.org`, etc.). +v0.2.0 uses a single `inbox.org` with a `TO:` property on each entry. + +If you have existing messages you want to preserve, concatenate them: + +```bash +cd 1-datafund/org/messaging +cat gregor.org crt.org tex.org >> inbox.org +# Then remove the per-user files: +rm gregor.org crt.org tex.org +``` + +Review the merged `inbox.org` to ensure headings are valid org-mode and +each entry has a `TO:` property in its `:PROPERTIES:` drawer. + +**3. Convert USERS.yaml → contacts.yaml** + +Old format (`org/inboxes/USERS.yaml`): + +```yaml +users: + gregor: + handles: ["@gregor", "@gz"] + added: 2025-12-11 +``` + +New format (`{space}/contacts.yaml`): + +```yaml +actors: + - id: "gregor@team.example.com" + name: "gregor" + handles: ["@gregor", "@gz"] + trust_tier: team + added: 2025-12-11 +``` + +Copy the template and fill in your actors: + +```bash +cp templates/contacts.yaml 1-datafund/contacts.yaml +# Edit 1-datafund/contacts.yaml and add your actors +``` + +**4. Remove the old registry file** + +```bash +rm 1-datafund/org/messaging/USERS.yaml +``` + +**5. Verify** + +```bash +# Check the new layout +ls 1-datafund/org/messaging/ +# Expected: inbox.org outbox.org agents/ + +ls 1-datafund/contacts.yaml +# Expected: contacts.yaml +``` + +### What's new in v0.2.0 + +- Universal inbox (`inbox.org`) replaces per-user files — simpler to sync +- `contacts.yaml` at space root with trust tier support +- Agent task inboxes under `org/messaging/agents/{username}-claude.org` +- WebSocket relay for real-time delivery (optional) +- Task governance: trust tiers, token budgets, rate limiting diff --git a/commands/broadcast.md b/commands/broadcast.md index 0d0c385..8aa4abd 100644 --- a/commands/broadcast.md +++ b/commands/broadcast.md @@ -30,13 +30,13 @@ Send a message to all users in a space. - Otherwise prompt 3. **Get all users in space** - - Read `[space]/org/inboxes/USERS.yaml` + - Read `[space]/contacts.yaml` - Exclude sender - Exclude `--exclude` list - - Exclude AI users (type: ai) + - Exclude AI users (trust_tier: ai) 4. **Send to each user** - - Append message to each user's inbox + - Append message to `[space]/org/messaging/inbox.org` - Use special `:broadcast:` tag 5. **Confirm** diff --git a/commands/msg-add-user.md b/commands/msg-add-user.md index 5c4d506..614fe77 100644 --- a/commands/msg-add-user.md +++ b/commands/msg-add-user.md @@ -26,12 +26,12 @@ Add a new user to the messaging system. - If `--space` specified, use that space only - Otherwise, add to all team spaces -3. **Create inbox file** - - Path: `[space]/org/inboxes/{username}.org` - - Use template from `templates/inbox.org` +3. **Create messaging directory if needed** + - Path: `[space]/org/messaging/` + - Agent inbox: `[space]/org/messaging/agents/{username}-claude.org` -4. **Update USERS.yaml** - - Add user entry with handles +4. **Update contacts.yaml** + - Add actor entry with handles and trust tier - Set `added` date 5. **Confirm** @@ -39,23 +39,23 @@ Add a new user to the messaging system. ✓ User 'crt' added to messaging Spaces: datafund Handles: @crt, @crtahlin - Inbox: 1-datafund/org/inboxes/crt.org + Contacts: 1-datafund/contacts.yaml ``` -## USERS.yaml Format +## contacts.yaml Format ```yaml -users: - gregor: +actors: + - id: "gregor@team.example.com" + name: "gregor" handles: ["@gregor", "@gz"] + trust_tier: owner added: 2025-12-11 - crt: + - id: "crt@team.example.com" + name: "crt" handles: ["@crt", "@crtahlin"] + trust_tier: team added: 2025-12-11 - claude: - handles: ["@claude", "@ai"] - added: 2025-12-11 - type: ai ``` ## Examples diff --git a/commands/msg.md b/commands/msg.md index 451a90a..f79b910 100644 --- a/commands/msg.md +++ b/commands/msg.md @@ -24,7 +24,7 @@ Send a message to another user in a shared space. - If not set, prompt user to configure identity 2. **Resolve recipient** - - Read `[space]/org/inboxes/USERS.yaml` + - Read `[space]/contacts.yaml` - Match `@recipient` against handles or usernames - If not found, treat as new user (create inbox) @@ -34,9 +34,9 @@ Send a message to another user in a shared space. - Otherwise detect from current directory - Otherwise prompt user to specify -4. **Create inbox directory if needed** +4. **Create messaging directory if needed** ``` - [space]/org/inboxes/ + [space]/org/messaging/ ``` 5. **Generate message ID** @@ -44,18 +44,18 @@ Send a message to another user in a shared space. msg-{YYYYMMDD}-{HHMMSS}-{sender} ``` -6. **Append message to recipient's inbox** - - File: `[space]/org/inboxes/{recipient}.org` +6. **Append message to inbox** + - File: `[space]/org/messaging/inbox.org` - Create file if doesn't exist -7. **Update USERS.yaml if new user** +7. **Update contacts.yaml if new user** - Add recipient to registry with handle 8. **Confirm delivery** ``` ✓ Message sent to @recipient Space: datafund - File: 1-datafund/org/inboxes/recipient.org + File: 1-datafund/org/messaging/inbox.org Run ./sync push to deliver. ``` diff --git a/commands/my-messages.md b/commands/my-messages.md index 3dbb576..54d3cb1 100644 --- a/commands/my-messages.md +++ b/commands/my-messages.md @@ -25,8 +25,8 @@ Display your inbox - unread and recent messages. - If not set, prompt user to configure 2. **Find all inbox files** - - Scan all spaces: `*/org/inboxes/{identity.name}.org` - - Include personal space if exists: `0-personal/org/inboxes/{name}.org` + - Scan all spaces: `*/org/messaging/inbox.org` + - Include personal space if exists: `0-personal/org/messaging/inbox.org` 3. **Parse messages** - Read org-mode entries from each inbox diff --git a/commands/reply.md b/commands/reply.md index 5e48045..42892ef 100644 --- a/commands/reply.md +++ b/commands/reply.md @@ -32,8 +32,8 @@ Reply to a message, creating a threaded conversation. - Set `TO` to original sender - Set `FROM` to current user -4. **Append to original sender's inbox** - - File: `[space]/org/inboxes/{original_sender}.org` +4. **Append to messaging inbox** + - File: `[space]/org/messaging/inbox.org` 5. **Mark original as replied** - Add `:replied:` tag to original message heading @@ -42,7 +42,7 @@ Reply to a message, creating a threaded conversation. ``` ✓ Reply sent to @gregor Thread: msg-20251211-143000-gregor - File: 1-datafund/org/inboxes/gregor.org + File: 1-datafund/org/messaging/inbox.org Run ./sync push to deliver. ``` diff --git a/lib/relay.py b/lib/relay.py index 7e50760..ad47d90 100644 --- a/lib/relay.py +++ b/lib/relay.py @@ -25,10 +25,9 @@ class User: class RelayServer: - def __init__(self, secret: str, claude_whitelist: dict | None = None): + def __init__(self, secret: str): self.secret = secret self.users: dict[str, User] = {} - self.claude_whitelist = claude_whitelist or {} def add_user(self, username: str, ws: web.WebSocketResponse) -> None: self.users[username] = User(username=username, ws=ws) @@ -64,7 +63,12 @@ async def broadcast_presence(self, username: str, status: str) -> None: def create_relay_app(relay_secret: str) -> web.Application: - """Create the aiohttp relay application. Raises ValueError if relay_secret is empty.""" + """Create the aiohttp relay application. Raises ValueError if relay_secret is empty. + + TLS note: this server speaks plain HTTP/WS. TLS termination must be handled + by a reverse proxy (nginx, Caddy, etc.) in front of this process. Clients + should connect via wss:// through the proxy, never directly to this port. + """ if not relay_secret: raise ValueError( "RELAY_SECRET must be set. " @@ -150,6 +154,12 @@ def run_relay() -> None: args = parse_relay_args() secret = os.environ.get("RELAY_SECRET", "") app = create_relay_app(relay_secret=secret) + logger.info( + "Relay listening on %s:%s — TLS must be terminated by a reverse proxy " + "(nginx, Caddy). Clients must connect via wss://, not ws://.", + args.bind, + args.port, + ) web.run_app(app, host=args.bind, port=args.port) diff --git a/module.yaml b/module.yaml index bbc6bdb..4406307 100644 --- a/module.yaml +++ b/module.yaml @@ -14,6 +14,18 @@ dependencies: # What this module provides (spec-compliant format) provides: + # Hooks are fully implemented and active + hooks: + - today # Unread message count in /today briefing + - task_check # Agent task check on prompt submit + - send_reply # Reply helper for Claude + - mark_message # Mark messages read/archived + - task_queue # Approve/reject/cancel queued tasks + +# Phase 2 — CLI commands and agents are spec-only documents; +# executable implementation is planned for Phase 2. +# See commands/*.md and agents/*.md for specifications. +planned: commands: - msg - my-messages From 11b53dcb59edcb3806c2ca9b0d94a0a4572aec8a Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 21:47:07 +0100 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20address=20iteration=202=20review?= =?UTF-8?q?=20=E2=80=94=20stale=20settings=20example,=20governor=20read=20?= =?UTF-8?q?freshness,=20deprecation=20notices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.local.yaml.example: remove dead claude_whitelist section, add v0.2.0 sections (trust_overrides, trust_tiers, compute) matching lib/config.py - lib/governor.py: add self._load_state() at top of get_usage() and daily_summary() so they reload from disk before returning data - datacore-msg.py: add prominent deprecation notice in module docstring and startup warning printed to stderr at runtime - templates/contacts.yaml: update comment to reference trust_overrides in settings.local.yaml instead of dead /msg-trust command - UPGRADING.md: document GUI app incompatibility with v0.2.0, expected failure modes, and Phase 2 rewrite plan - relay/Dockerfile: verified correct (COPY lib/ lib/ + lib.relay --host) Co-Authored-By: Claude Opus 4.6 --- UPGRADING.md | 12 ++++++++++++ datacore-msg.py | 15 +++++++++++++++ lib/governor.py | 3 +++ settings.local.yaml.example | 33 +++++++++++++++++++++++++-------- templates/contacts.yaml | 2 +- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index aa54ae9..52f94f2 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -91,3 +91,15 @@ ls 1-datafund/contacts.yaml - Agent task inboxes under `org/messaging/agents/{username}-claude.org` - WebSocket relay for real-time delivery (optional) - Task governance: trust tiers, token budgets, rate limiting + +### GUI app (`datacore-msg.py`) status + +`datacore-msg.py` is **not compatible with v0.2.0**. It still references +the old `org/inboxes/` storage layout and `claude_whitelist` settings. + +A rewritten GUI is planned for Phase 2. Until then: + +- Use `hooks/` scripts for message delivery +- Use `lib/` API directly for programmatic access +- Running `datacore-msg.py` will print a warning to stderr and continue + in degraded mode (display only — writes may fail silently) diff --git a/datacore-msg.py b/datacore-msg.py index 1b04eba..66eeb55 100755 --- a/datacore-msg.py +++ b/datacore-msg.py @@ -2,6 +2,15 @@ """ datacore-msg - Unified messaging app for Datacore +DEPRECATED / NOT YET COMPATIBLE WITH v0.2.0 +-------------------------------------------- +This GUI application was written for v0.1.0 storage layout +(org/inboxes/ paths) and has NOT been updated for v0.2.0 +(org/messaging/ layout, contacts.yaml, trust tiers). + +It will be rewritten in Phase 2. Until then, use the hooks/ +and lib/ API directly. See UPGRADING.md for details. + Single process that runs: - GUI window for sending/receiving messages - Relay server (if hosting) for real-time delivery @@ -1446,6 +1455,12 @@ async def _connect_relay(self): def main(): + print( + "WARNING: datacore-msg.py is not yet compatible with v0.2.0 storage layout. " + "Use hooks/ or lib/ API directly. See UPGRADING.md for details.", + file=sys.stderr, + ) + host_relay = "--host" in sys.argv or "-h" in sys.argv if not DATACORE_ROOT.exists(): diff --git a/lib/governor.py b/lib/governor.py index 208438d..c440c7e 100644 --- a/lib/governor.py +++ b/lib/governor.py @@ -275,12 +275,15 @@ def record_task_completion(self, actor_id: str) -> None: def get_usage(self, actor_id: str) -> dict: """Get usage stats for an actor.""" + self._load_state() self._check_date_rollover() sender = self._get_sender(actor_id) return {"tokens_today": sender.tokens_today, "tasks_today": sender.tasks_today} def daily_summary(self) -> dict: """Get a summary of today's usage across all senders.""" + self._load_state() + self._check_date_rollover() total_tokens = sum(s.tokens_today for s in self._state.values()) by_sender = { actor: {"tokens": s.tokens_today, "tasks": s.tasks_today} diff --git a/settings.local.yaml.example b/settings.local.yaml.example index a931222..0feb12d 100644 --- a/settings.local.yaml.example +++ b/settings.local.yaml.example @@ -8,13 +8,6 @@ identity: messaging: default_space: 1-team # Space for message inboxes (e.g., 1-datafund) - # Users allowed to message YOUR Claude agent (@yourname-claude) - # Others get auto-reply without involving the LLM - claude_whitelist: - - gregor # @gregor can message @yourname-claude - - crt # @crt can message @yourname-claude - # Leave empty [] to allow everyone, or omit to disable whitelist - relay: secret: "your-team-shared-secret" # Same secret for all team members @@ -24,9 +17,33 @@ messaging: # url: "ws://relay.internal.company.com:8080/ws" # Internal server (no SSL) url: "wss://datacore-messaging-relay.datafund.ai/ws" # Public relay (with SSL) + # Trust overrides: assign specific actors to trust tiers + # trust_overrides: + # gregor@team.example.com: team + # crt@team.example.com: team + # partner@external.com: trusted + + # Optional: override trust tier defaults (see lib/config.py for full schema) + # trust_tiers: + # team: + # daily_token_limit: 200000 + # trusted: + # auto_accept: true + + # Optional: override compute budget defaults + # compute: + # daily_budget_tokens: 500000 + # per_sender_daily_max: 100000 + # per_task_max_tokens: 50000 + # per_task_timeout_minutes: 30 + # cooldown_between_tasks: 60 + # max_queue_depth: 20 + # rate_limits: + # tasks_per_hour: 5 + # Routing: # @claude → automatically routes to @-claude (your own Claude) -# @gregor-claude → goes to Gregor's Claude (if you're whitelisted) +# @gregor-claude → goes to Gregor's Claude (if you're in their trust_overrides) # # User naming convention: # @yourname - Your human identity (GUI window) diff --git a/templates/contacts.yaml b/templates/contacts.yaml index d30c8a3..dc61872 100644 --- a/templates/contacts.yaml +++ b/templates/contacts.yaml @@ -1,5 +1,5 @@ # Known ActivityPub actors for this space -# Add actors via /msg-trust or manually edit this file +# Add actors via trust_overrides in settings.local.yaml or manually edit this file actors: [] # Example: # - id: "tex@team.example.com" From b2d234c0b8fac7653976ba80dfb0e64734e30eb2 Mon Sep 17 00:00:00 2001 From: plur9 Date: Wed, 11 Mar 2026 22:14:15 +0100 Subject: [PATCH 17/17] docs: add Phase 1 implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-11-phase1-foundation.md | 2551 +++++++++++++++++ 1 file changed, 2551 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-11-phase1-foundation.md diff --git a/docs/superpowers/plans/2026-03-11-phase1-foundation.md b/docs/superpowers/plans/2026-03-11-phase1-foundation.md new file mode 100644 index 0000000..5585d22 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-phase1-foundation.md @@ -0,0 +1,2551 @@ +# Phase 1: Foundation — org-workspace, Governance, Bug Fixes + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the datacore-messaging prototype to use org-workspace for storage, add task governance (trust tiers, budgets, rate limits), fix all critical/high audit issues, and add tests. This is Phase 1 of DIP-0023. + +**Architecture:** Extract shared utilities into a `lib/config.py` module. Replace all raw file manipulation with org-workspace API calls through a `lib/message_store.py` abstraction. Add `lib/governor.py` for task governance. Consolidate relay into single `lib/relay.py`. Keep existing GUI and hooks functional but rewired to use the new library layer. + +**Tech Stack:** Python 3.10+, org-workspace (PyPI), aiohttp, websockets, PyQt6, pytest + +**PR Target:** `datafund/datacore-messaging` main branch + +--- + +## Chunk 1: Project Setup and Shared Utilities + +### Task 1: Add org-workspace dependency and update project config + +**Files:** +- Modify: `requirements.txt` +- Create: `pyproject.toml` +- Create: `tests/__init__.py` +- Create: `tests/conftest.py` + +- [ ] **Step 1: Update requirements.txt** + +``` +org-workspace>=0.3.0 +aiohttp>=3.9.0 +websockets>=12.0 +pyyaml>=6.0 +``` + +- [ ] **Step 2: Create pyproject.toml for test configuration** + +```toml +[project] +name = "datacore-messaging" +version = "0.2.0" +requires-python = ">=3.10" +dependencies = [ + "org-workspace>=0.3.0", + "aiohttp>=3.9.0", + "websockets>=12.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +gui = ["PyQt6>=6.5.0"] +dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "pytest-aiohttp>=1.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +``` + +- [ ] **Step 3: Create test scaffolding** + +Create `tests/__init__.py` (empty) and `tests/conftest.py`: + +```python +import os +import tempfile +from pathlib import Path + +import pytest +from org_workspace import OrgWorkspace, StateConfig + + +@pytest.fixture +def tmp_space(tmp_path): + """Create a temporary Datacore space with messaging directories.""" + org_dir = tmp_path / "org" / "messaging" + org_dir.mkdir(parents=True) + agents_dir = org_dir / "agents" + agents_dir.mkdir() + + # Write inbox.org with proper TODO keywords + inbox = org_dir / "inbox.org" + inbox.write_text( + "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + ) + + # Write agent inbox + agent_inbox = agents_dir / "test-claude.org" + agent_inbox.write_text( + "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + ) + + return tmp_path + + +@pytest.fixture +def msg_state_config(): + """StateConfig for message storage (inbox.org). DONE is terminal for messages.""" + return StateConfig( + active=["TODO", "WAITING"], + terminal=["DONE", "CANCELLED", "ARCHIVED"], + ) + + +@pytest.fixture +def task_state_config(): + """StateConfig for agent tasks. DONE is NOT terminal — allows revision cycle.""" + return StateConfig( + active=["TODO", "WAITING", "QUEUED", "WORKING", "DONE"], + terminal=["CANCELLED", "ARCHIVED"], + ) + + +@pytest.fixture +def workspace(tmp_space, msg_state_config): + """Pre-loaded OrgWorkspace for messaging tests.""" + ws = OrgWorkspace(state_config=msg_state_config) + inbox = tmp_space / "org" / "messaging" / "inbox.org" + ws.load(inbox) + return ws +``` + +- [ ] **Step 4: Install dev dependencies and verify** + +Run: `pip install -e ".[dev]"` (from repo root) +Run: `pytest --co -q` +Expected: `no tests ran` (collection succeeds, no tests yet) + +- [ ] **Step 5: Commit** + +```bash +git add requirements.txt pyproject.toml tests/ +git commit -m "chore: add org-workspace dependency, pytest config, test scaffolding" +``` + +--- + +### Task 2: Extract shared config module + +**Files:** +- Create: `lib/__init__.py` +- Create: `lib/config.py` +- Create: `tests/test_config.py` + +Currently `get_username()`, `get_settings()`, `get_default_space()`, `get_relay_url()`, `get_relay_secret()` are duplicated across 5+ files. Extract into one module. + +- [ ] **Step 1: Write failing tests for config** + +```python +# tests/test_config.py +import os +from pathlib import Path + +import pytest +from lib.config import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _fresh_config(): + """Clear settings cache before each test to prevent cross-test pollution.""" + clear_settings_cache() + yield + clear_settings_cache() + + +def _write_settings(tmp_path, content: str): + """Write settings to correct .datacore/ path.""" + dc_dir = tmp_path / ".datacore" + dc_dir.mkdir(exist_ok=True) + (dc_dir / "settings.local.yaml").write_text(content) + + +def test_get_username_from_settings(tmp_path, monkeypatch): + from lib.config import get_username + + _write_settings(tmp_path, "identity:\n name: testuser\n") + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + assert get_username() == "testuser" + + +def test_get_username_fallback_to_env(monkeypatch): + from lib.config import get_username + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + monkeypatch.setenv("USER", "envuser") + assert get_username() == "envuser" + + +def test_get_settings_returns_empty_on_missing(monkeypatch): + from lib.config import get_settings + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + result = get_settings() + assert result == {} + + +def test_get_default_space(tmp_path, monkeypatch): + from lib.config import get_default_space + + _write_settings(tmp_path, "messaging:\n default_space: 0-personal\n") + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + assert get_default_space() == "0-personal" + + +def test_get_relay_url_default(monkeypatch): + from lib.config import get_relay_url + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + url = get_relay_url() + assert url == "wss://datacore-messaging-relay.datafund.ai/ws" + + +def test_get_trust_tier_defaults(monkeypatch): + from lib.config import get_trust_tier + + monkeypatch.setenv("DATACORE_ROOT", "/nonexistent") + tier = get_trust_tier("unknown@example.com") + assert tier == "unknown" + + +def test_get_trust_tier_override(tmp_path, monkeypatch): + from lib.config import get_trust_tier + + _write_settings( + tmp_path, + "messaging:\n" + " trust_overrides:\n" + ' "tex@team.example.com": team\n', + ) + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + assert get_trust_tier("tex@team.example.com") == "team" + assert get_trust_tier("random@example.com") == "unknown" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_config.py -v` +Expected: FAIL (lib.config doesn't exist) + +- [ ] **Step 3: Implement lib/config.py** + +```python +# lib/__init__.py +"""Datacore messaging library.""" +import sys +from pathlib import Path + +# Ensure lib/ is importable from hooks/ (hooks run standalone) +_LIB_DIR = Path(__file__).resolve().parent +_REPO_DIR = _LIB_DIR.parent +if str(_REPO_DIR) not in sys.path: + sys.path.insert(0, str(_REPO_DIR)) + +# lib/config.py +"""Shared configuration for datacore-messaging. + +Single source of truth for settings, identity, relay config, +and trust tier resolution. Replaces duplicated getters across +hooks, GUI, and relay code. +""" + +import os +from pathlib import Path +from typing import Any + +_settings_cache: dict | None = None +_RELAY_URL_DEFAULT = "wss://datacore-messaging-relay.datafund.ai/ws" + +_TRUST_TIER_DEFAULTS = { + "owner": { + "priority_boost": 2.0, + "daily_token_limit": 0, + "max_task_effort": 0, + "auto_accept": True, + }, + "team": { + "priority_boost": 1.5, + "daily_token_limit": 100_000, + "max_task_effort": 8, + "auto_accept": True, + }, + "trusted": { + "priority_boost": 1.0, + "daily_token_limit": 50_000, + "max_task_effort": 5, + "auto_accept": False, + }, + "unknown": { + "priority_boost": 0.5, + "daily_token_limit": 10_000, + "max_task_effort": 3, + "auto_accept": False, + }, +} + + +def datacore_root() -> Path: + return Path(os.environ.get("DATACORE_ROOT", str(Path.home() / "Data"))) + + +def get_settings() -> dict[str, Any]: + """Load settings from .datacore/settings.local.yaml, with caching. + + Call clear_settings_cache() in tests before each test that uses + monkeypatch to change DATACORE_ROOT. + """ + global _settings_cache + if _settings_cache is not None: + return _settings_cache + + try: + import yaml + except ImportError: + return {} + + # Datacore stores settings at .datacore/settings.local.yaml + settings_path = datacore_root() / ".datacore" / "settings.local.yaml" + if not settings_path.exists(): + return {} + + try: + with open(settings_path) as f: + _settings_cache = yaml.safe_load(f) or {} + except Exception: + _settings_cache = {} + + return _settings_cache + + +def clear_settings_cache() -> None: + """Clear cached settings. Call in test fixtures, not in production code.""" + global _settings_cache + _settings_cache = None + + +def get_username() -> str: + """Get messaging username from settings or environment.""" + settings = get_settings() + name = settings.get("identity", {}).get("name", "") + if name: + return name + return os.environ.get("USER", "unknown") + + +def get_default_space() -> str: + """Get default messaging space.""" + settings = get_settings() + return settings.get("messaging", {}).get("default_space", "0-personal") + + +def get_relay_url() -> str: + """Get relay WebSocket URL.""" + settings = get_settings() + url = settings.get("messaging", {}).get("relay", {}).get("url", "") + return url or _RELAY_URL_DEFAULT + + +def get_relay_secret() -> str: + """Get relay authentication secret.""" + settings = get_settings() + secret = settings.get("messaging", {}).get("relay", {}).get("secret", "") + return secret or os.environ.get("RELAY_SECRET", "") + + +def get_trust_tier(actor_id: str) -> str: + """Resolve trust tier for an actor. Returns tier name.""" + settings = get_settings() + overrides = settings.get("messaging", {}).get("trust_overrides", {}) + if actor_id in overrides: + return overrides[actor_id] + return "unknown" + + +def get_trust_tier_config(tier_name: str) -> dict[str, Any]: + """Get configuration for a trust tier.""" + settings = get_settings() + custom_tiers = settings.get("messaging", {}).get("trust_tiers", {}) + if tier_name in custom_tiers: + merged = dict(_TRUST_TIER_DEFAULTS.get(tier_name, {})) + merged.update(custom_tiers[tier_name]) + return merged + return dict(_TRUST_TIER_DEFAULTS.get(tier_name, _TRUST_TIER_DEFAULTS["unknown"])) + + +def get_compute_config() -> dict[str, Any]: + """Get compute budget configuration.""" + settings = get_settings() + defaults = { + "daily_budget_tokens": 500_000, + "per_sender_daily_max": 100_000, + "per_task_max_tokens": 50_000, + "per_task_timeout_minutes": 30, + "cooldown_between_tasks": 60, + "max_queue_depth": 20, + } + custom = settings.get("messaging", {}).get("compute", {}) + defaults.update(custom) + return defaults + + +def messaging_dir(space: str | None = None) -> Path: + """Get the messaging org directory for a space.""" + root = datacore_root() + space = space or get_default_space() + return root / space / "org" / "messaging" + + +def agent_inbox_path(agent_name: str, space: str | None = None) -> Path: + """Get the inbox file path for a specific agent.""" + return messaging_dir(space) / "agents" / f"{agent_name}.org" +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_config.py -v` +Expected: All 7 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/__init__.py lib/config.py tests/test_config.py +git commit -m "feat: extract shared config module — single source for settings, trust tiers, paths" +``` + +--- + +## Chunk 2: Message Store (org-workspace integration) + +### Task 3: Implement message store with org-workspace + +**Files:** +- Create: `lib/message_store.py` +- Create: `tests/test_message_store.py` + +This is the core replacement for all raw file manipulation. Every message read/write goes through this module. + +- [ ] **Step 1: Write failing tests for message store** + +```python +# tests/test_message_store.py +from datetime import datetime +from pathlib import Path + +import pytest +from org_workspace import OrgWorkspace, StateConfig + + +@pytest.fixture +def store(tmp_space, msg_state_config): + from lib.message_store import MessageStore + + return MessageStore(tmp_space, state_config=msg_state_config) + + +class TestCreateMessage: + def test_create_message_basic(self, store): + node = store.create_message( + from_actor="gregor@example.com", + to_actor="tex@example.com", + content="Hello from tests", + ) + assert node.todo == "TODO" + assert "message" in node.shallow_tags + assert "unread" in node.shallow_tags + assert node.properties["FROM"] == "gregor@example.com" + assert node.properties["TO"] == "tex@example.com" + assert "msg-" in node.properties["ID"] + + def test_create_message_with_thread(self, store): + parent = store.create_message( + from_actor="tex@example.com", + to_actor="gregor@example.com", + content="Original", + ) + reply = store.create_message( + from_actor="gregor@example.com", + to_actor="tex@example.com", + content="Reply", + reply_to=parent.properties["ID"], + ) + assert reply.properties["REPLY_TO"] == parent.properties["ID"] + assert reply.properties["THREAD"] == parent.properties["ID"] + + def test_message_ids_are_unique(self, store): + msg1 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="hello" + ) + msg2 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="different" + ) + assert msg1.properties["ID"] != msg2.properties["ID"] + + def test_message_id_same_content_different_time(self, store): + # Same content at different times should still differ (timestamp in ID) + msg1 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="same" + ) + msg2 = store.create_message( + from_actor="a@x.com", to_actor="b@x.com", content="same" + ) + # IDs may collide within same second — that's OK for tests + # In production, content hash includes timestamp + + +class TestQueryMessages: + def test_find_unread(self, store): + store.create_message("a@x.com", "b@x.com", "msg1") + store.create_message("a@x.com", "b@x.com", "msg2") + unread = store.find_unread() + assert len(unread) == 2 + + def test_find_unread_after_mark_read(self, store): + msg = store.create_message("a@x.com", "b@x.com", "test") + store.mark_read(msg) + unread = store.find_unread() + assert len(unread) == 0 + + def test_find_by_thread(self, store): + parent = store.create_message("a@x.com", "b@x.com", "parent") + pid = parent.properties["ID"] + store.create_message("b@x.com", "a@x.com", "reply1", reply_to=pid) + store.create_message("b@x.com", "a@x.com", "reply2", reply_to=pid) + thread = store.find_thread(pid) + assert len(thread) >= 2 # replies + + +class TestMarkOperations: + def test_mark_read(self, store): + msg = store.create_message("a@x.com", "b@x.com", "test") + store.mark_read(msg) + assert msg.todo == "DONE" + + def test_mark_archived(self, store): + msg = store.create_message("a@x.com", "b@x.com", "test") + store.mark_read(msg) + store.archive(msg) + assert msg.todo == "ARCHIVED" + + +class TestFileDelivery: + def test_create_file_delivery(self, store): + node = store.create_file_delivery( + from_actor="tex@example.com", + filename="report.pdf", + size=2400000, + content_type="application/pdf", + swarm_ref="abc123", + ) + assert node.todo == "TODO" + assert "file_delivery" in node.shallow_tags + assert "unread" in node.shallow_tags + assert node.properties["FILENAME"] == "report.pdf" + assert node.properties["SIZE"] == "2400000" + assert node.properties["DOWNLOADED"] == "false" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_message_store.py -v` +Expected: FAIL (lib.message_store doesn't exist) + +- [ ] **Step 3: Implement lib/message_store.py** + +```python +# lib/message_store.py +"""Message storage layer built on org-workspace. + +All message CRUD operations go through this module. +Handles message creation, querying, state transitions, +threading, and file delivery notifications. + +Message IDs are timestamp-unique (not content-addressed): +format is msg-YYYYMMDD-HHMMSS-{hash[:8]} where hash includes +microseconds to minimize collision risk within the same second. +""" + +import hashlib +import os +from datetime import datetime +from pathlib import Path + +from org_workspace import OrgWorkspace, StateConfig, NodeView +from org_workspace.concurrency import FileLock + + +_TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + +# Messages use a simpler state machine than tasks: +# TODO (unread) -> DONE (read) -> ARCHIVED +_MSG_STATE_CONFIG = StateConfig( + active=["TODO", "WAITING"], + terminal=["DONE", "CANCELLED", "ARCHIVED"], +) + + +def _unique_msg_id(from_actor: str, to_actor: str, content: str) -> str: + """Generate a timestamp-unique message ID. + + NOT content-addressed — includes microseconds for uniqueness. + Two identical messages sent in rapid succession get different IDs. + """ + now = datetime.now() + ts = now.strftime("%Y%m%d-%H%M%S") + raw = f"{from_actor}:{to_actor}:{content}:{now.isoformat()}" + h = hashlib.sha256(raw.encode()).hexdigest()[:8] + return f"msg-{ts}-{h}" + + +def _unique_file_id(from_actor: str, filename: str) -> str: + """Generate a timestamp-unique file delivery ID.""" + now = datetime.now() + ts = now.strftime("%Y%m%d-%H%M%S") + raw = f"{from_actor}:{filename}:{now.isoformat()}" + h = hashlib.sha256(raw.encode()).hexdigest()[:8] + return f"file-{ts}-{h}" + + +class MessageStore: + """org-workspace backed message storage.""" + + def __init__( + self, + space_root: Path, + state_config: StateConfig | None = None, + ): + self._root = Path(space_root) + self._msg_dir = self._root / "org" / "messaging" + self._agents_dir = self._msg_dir / "agents" + self._inbox_path = self._msg_dir / "inbox.org" + self._outbox_path = self._msg_dir / "outbox.org" + + self._ws = OrgWorkspace(state_config=state_config or _MSG_STATE_CONFIG) + self._ensure_files() + self._ws.load(self._inbox_path) + + def _ensure_files(self) -> None: + """Create messaging directories and files if missing (atomic).""" + self._msg_dir.mkdir(parents=True, exist_ok=True) + self._agents_dir.mkdir(exist_ok=True) + for p in (self._inbox_path, self._outbox_path): + try: + fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, _TODO_HEADER.encode()) + os.close(fd) + except FileExistsError: + pass # Already exists — no race + + def create_message( + self, + from_actor: str, + to_actor: str, + content: str, + reply_to: str | None = None, + priority: str | None = None, + **extra_props: str, + ) -> NodeView: + """Create a new message in the inbox.""" + msg_id = _unique_msg_id(from_actor, to_actor, content) + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + heading = f"{now_str}" + + props = { + "FROM": from_actor, + "TO": to_actor, + **extra_props, + } + if reply_to: + props["REPLY_TO"] = reply_to + # Thread ID = the root message of the thread. + # For nested replies, look up the parent's THREAD property. + # If the parent has no THREAD, it IS the root. + parent = self._ws.find_by_id(reply_to) + if parent and parent.properties.get("THREAD"): + props["THREAD"] = parent.properties["THREAD"] + else: + props["THREAD"] = reply_to + if priority: + props["PRIORITY"] = priority + + with FileLock(self._inbox_path): + node = self._ws.create_node( + file=self._inbox_path, + heading=heading, + state="TODO", + tags=["unread", "message"], + body=content, + ID=msg_id, + **props, + ) + self._ws.save(self._inbox_path) + + return node + + def create_file_delivery( + self, + from_actor: str, + filename: str, + size: int, + content_type: str, + swarm_ref: str, + fairdrop_ref: str = "", + ) -> NodeView: + """Create a file delivery notification in the inbox.""" + file_id = _unique_file_id(from_actor, filename) + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + heading = f"{now_str}" + + with FileLock(self._inbox_path): + node = self._ws.create_node( + file=self._inbox_path, + heading=heading, + state="TODO", + tags=["unread", "file_delivery"], + body=f"File delivery via Fairdrop. Download to process.", + ID=file_id, + FROM=from_actor, + FILENAME=filename, + SIZE=str(size), + CONTENT_TYPE=content_type, + SWARM_REF=swarm_ref, + FAIRDROP_REF=fairdrop_ref, + DOWNLOADED="false", + ) + self._ws.save(self._inbox_path) + + return node + + def find_unread(self) -> list[NodeView]: + """Find all unread messages.""" + self._ws.reload(self._inbox_path) + return [n for n in self._ws.find_by_tag("unread") if n.todo == "TODO"] + + def find_messages(self) -> list[NodeView]: + """Find all message nodes (any state).""" + self._ws.reload(self._inbox_path) + return [n for n in self._ws.find_by_tag("message")] + + def find_file_deliveries(self, unread_only: bool = True) -> list[NodeView]: + """Find file delivery notifications.""" + self._ws.reload(self._inbox_path) + nodes = self._ws.find_by_tag("file_delivery") + if unread_only: + nodes = [n for n in nodes if "unread" in n.shallow_tags] + return nodes + + def find_thread(self, thread_root_id: str) -> list[NodeView]: + """Find all messages in a thread (by root message ID). + + The THREAD property on each reply stores the root message's full ID. + """ + self._ws.reload(self._inbox_path) + return [ + n + for n in self._ws.all_nodes() + if n.properties.get("THREAD") == thread_root_id + ] + + def find_by_id(self, msg_id: str) -> NodeView | None: + """Find a message by its ID.""" + return self._ws.find_by_id(msg_id) + + def mark_read(self, node: NodeView) -> None: + """Mark message as read (TODO -> DONE).""" + with FileLock(self._inbox_path): + tags = list(node.shallow_tags - {"unread"}) + self._ws.set_tags(node, tags) + self._ws.transition(node, "DONE") + self._ws.save(self._inbox_path) + + def archive(self, node: NodeView) -> None: + """Archive a message (DONE -> ARCHIVED).""" + with FileLock(self._inbox_path): + self._ws.transition(node, "ARCHIVED") + self._ws.save(self._inbox_path) + + @property + def workspace(self) -> OrgWorkspace: + """Access underlying workspace (for advanced queries).""" + return self._ws +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_message_store.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/message_store.py tests/test_message_store.py +git commit -m "feat: add message store — org-workspace backed message CRUD with FileLock" +``` + +--- + +### Task 4: Implement agent inbox store + +**Files:** +- Create: `lib/agent_inbox.py` +- Create: `tests/test_agent_inbox.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_agent_inbox.py +from pathlib import Path + +import pytest +from org_workspace import StateConfig + + +@pytest.fixture +def agent_store(tmp_space, task_state_config): + from lib.agent_inbox import AgentInbox + + return AgentInbox( + space_root=tmp_space, + agent_name="test-claude", + state_config=task_state_config, + ) + + +class TestTaskCreation: + def test_create_task_auto_accept(self, agent_store): + node = agent_store.create_task( + from_actor="owner@example.com", + content="Research competitors", + trust_tier="owner", + tags=["AI", "research"], + ) + assert node.todo == "QUEUED" + assert node.properties["FROM"] == "owner@example.com" + assert node.properties["TRUST_TIER"] == "owner" + assert node.properties["APPROVAL"] == "auto_accepted" + + def test_create_task_needs_approval(self, agent_store): + node = agent_store.create_task( + from_actor="unknown@example.com", + content="Do something", + trust_tier="unknown", + tags=["AI"], + ) + assert node.todo == "WAITING" + assert node.properties["AWAITING"] == "owner-approval" + + def test_create_task_team_auto_accepts(self, agent_store): + node = agent_store.create_task( + from_actor="tex@team.com", + content="Draft report", + trust_tier="team", + tags=["AI", "content"], + ) + assert node.todo == "QUEUED" + + +class TestTaskLifecycle: + def test_approve_task(self, agent_store): + node = agent_store.create_task( + "unknown@x.com", "task", "unknown", ["AI"] + ) + assert node.todo == "WAITING" + agent_store.approve(node) + assert node.todo == "QUEUED" + + def test_reject_task(self, agent_store): + node = agent_store.create_task( + "unknown@x.com", "task", "unknown", ["AI"] + ) + agent_store.reject(node, reason="Not relevant") + assert node.todo == "CANCELLED" + + def test_claim_and_complete(self, agent_store): + node = agent_store.create_task( + "owner@x.com", "task", "owner", ["AI"] + ) + agent_store.claim(node) + assert node.todo == "WORKING" + assert node.properties.get("STARTED") + + agent_store.complete( + node, + tokens_used=5000, + cost="$0.08", + result_path="0-inbox/result.md", + quality_score=0.85, + ) + assert node.todo == "DONE" + assert node.properties["TOKENS_USED"] == "5000" + assert node.properties["QUALITY_SCORE"] == "0.85" + + def test_request_revision(self, agent_store): + node = agent_store.create_task( + "owner@x.com", "task", "owner", ["AI"] + ) + agent_store.claim(node) + agent_store.complete(node, tokens_used=100) + agent_store.request_revision(node, feedback="Needs more detail") + assert node.todo == "QUEUED" + + def test_cancel_queued_task(self, agent_store): + node = agent_store.create_task( + "owner@x.com", "task", "owner", ["AI"] + ) + assert node.todo == "QUEUED" + agent_store.cancel(node, reason="No longer needed") + assert node.todo == "CANCELLED" + assert node.properties["CANCELLATION_REASON"] == "No longer needed" + + def test_retry_failed_task(self, agent_store): + node = agent_store.create_task( + "owner@x.com", "task", "owner", ["AI"] + ) + agent_store.claim(node) + assert node.todo == "WORKING" + agent_store.retry(node, reason="Agent crashed") + assert node.todo == "QUEUED" + assert node.properties["RETRY_REASON"] == "Agent crashed" + assert int(node.properties.get("RETRY_COUNT", "0")) == 1 + + def test_precondition_reject_on_queued_raises(self, agent_store): + node = agent_store.create_task( + "owner@x.com", "task", "owner", ["AI"] + ) + assert node.todo == "QUEUED" + with pytest.raises(ValueError, match="Expected state WAITING"): + agent_store.reject(node) + + def test_precondition_claim_on_waiting_raises(self, agent_store): + node = agent_store.create_task( + "unknown@x.com", "task", "unknown", ["AI"] + ) + assert node.todo == "WAITING" + with pytest.raises(ValueError, match="Expected state QUEUED"): + agent_store.claim(node) + + +class TestQueries: + def test_find_queued(self, agent_store): + agent_store.create_task("a@x.com", "t1", "owner", ["AI"]) + agent_store.create_task("a@x.com", "t2", "owner", ["AI"]) + queued = agent_store.find_by_state("QUEUED") + assert len(queued) == 2 + + def test_find_waiting_approval(self, agent_store): + agent_store.create_task("a@x.com", "t1", "unknown", ["AI"]) + agent_store.create_task("b@x.com", "t2", "trusted", ["AI"]) + waiting = agent_store.find_awaiting_approval() + assert len(waiting) == 2 + + def test_counts(self, agent_store): + agent_store.create_task("a@x.com", "t1", "owner", ["AI"]) + agent_store.create_task("b@x.com", "t2", "unknown", ["AI"]) + counts = agent_store.counts() + assert counts["queued"] == 1 + assert counts["waiting"] == 1 + assert counts["total"] == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_agent_inbox.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement lib/agent_inbox.py** + +```python +# lib/agent_inbox.py +"""Agent inbox — per-agent task store with lifecycle management. + +State machine (DIP-0023 Section 5.2): + WAITING -> QUEUED (owner approves) + WAITING -> CANCELLED (owner rejects) + QUEUED -> WORKING (agent claims) + QUEUED -> CANCELLED (owner cancels) + WORKING -> DONE (execution complete) + WORKING -> QUEUED (retry on failure) + DONE -> ARCHIVED (owner approves result) + DONE -> QUEUED (owner requests revision) + +IMPORTANT: DONE is NOT terminal for tasks — it's an active state that +allows revision. Only CANCELLED and ARCHIVED are terminal. This differs +from message_store.py where DONE IS terminal for simple messages. +""" + +import os +from datetime import datetime +from pathlib import Path + +from org_workspace import OrgWorkspace, StateConfig, NodeView +from org_workspace.concurrency import FileLock + +from lib.config import get_trust_tier_config + + +_TODO_HEADER = "#+TODO: TODO WAITING QUEUED WORKING | DONE CANCELLED ARCHIVED\n" + +# Task StateConfig: DONE is active (allows DONE -> QUEUED revision) +_TASK_STATE_CONFIG = StateConfig( + active=["TODO", "WAITING", "QUEUED", "WORKING", "DONE"], + terminal=["CANCELLED", "ARCHIVED"], +) + + +def _should_auto_accept(trust_tier: str) -> bool: + """Check if a trust tier auto-accepts tasks. Single source of truth.""" + tier_config = get_trust_tier_config(trust_tier) + return tier_config.get("auto_accept", False) + + +def _assert_state(node: NodeView, expected: str, method: str) -> None: + """Precondition assertion for state transitions.""" + if node.todo != expected: + raise ValueError( + f"{method}: Expected state {expected}, got {node.todo}" + ) + + +class AgentInbox: + """Manages tasks for a single agent.""" + + def __init__( + self, + space_root: Path, + agent_name: str, + state_config: StateConfig | None = None, + ): + self._root = Path(space_root) + self._name = agent_name + self._inbox_path = ( + self._root / "org" / "messaging" / "agents" / f"{agent_name}.org" + ) + + self._ws = OrgWorkspace(state_config=state_config or _TASK_STATE_CONFIG) + self._ensure_file() + self._ws.load(self._inbox_path) + + def _ensure_file(self) -> None: + """Create agent inbox file if missing (atomic).""" + self._inbox_path.parent.mkdir(parents=True, exist_ok=True) + try: + fd = os.open(str(self._inbox_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, _TODO_HEADER.encode()) + os.close(fd) + except FileExistsError: + pass + + def _reload(self) -> None: + self._ws.reload(self._inbox_path) + + def create_task( + self, + from_actor: str, + content: str, + trust_tier: str, + tags: list[str], + effort: int | None = None, + estimated_tokens: int | None = None, + ) -> NodeView: + """Create a task in the agent inbox. + + Auto-accept is determined by trust tier config (from config.py). + """ + auto_accept = _should_auto_accept(trust_tier) + state = "QUEUED" if auto_accept else "WAITING" + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + + props = { + "FROM": from_actor, + "TRUST_TIER": trust_tier, + "SUBMITTED": now_str, + } + if auto_accept: + props["APPROVAL"] = "auto_accepted" + else: + props["AWAITING"] = "owner-approval" + if effort is not None: + props["EFFORT"] = str(effort) + if estimated_tokens is not None: + props["ESTIMATED_TOKENS"] = str(estimated_tokens) + cost = estimated_tokens / 1000 * 0.015 + props["ESTIMATED_COST"] = f"${cost:.2f}" + + with FileLock(self._inbox_path): + node = self._ws.create_node( + file=self._inbox_path, + heading=content, + state=state, + tags=tags, + body="", + **props, + ) + self._ws.save(self._inbox_path) + + return node + + def approve(self, node: NodeView) -> None: + """Approve a WAITING task -> QUEUED.""" + _assert_state(node, "WAITING", "approve") + with FileLock(self._inbox_path): + self._ws.set_property(node, "APPROVAL", "owner_approved") + self._ws.transition(node, "QUEUED") + self._ws.save(self._inbox_path) + + def reject(self, node: NodeView, reason: str = "") -> None: + """Reject a WAITING task -> CANCELLED.""" + _assert_state(node, "WAITING", "reject") + with FileLock(self._inbox_path): + if reason: + self._ws.set_property(node, "REJECTION_REASON", reason) + self._ws.transition(node, "CANCELLED") + self._ws.save(self._inbox_path) + + def cancel(self, node: NodeView, reason: str = "") -> None: + """Cancel a QUEUED task -> CANCELLED.""" + _assert_state(node, "QUEUED", "cancel") + with FileLock(self._inbox_path): + if reason: + self._ws.set_property(node, "CANCELLATION_REASON", reason) + self._ws.transition(node, "CANCELLED") + self._ws.save(self._inbox_path) + + def claim(self, node: NodeView) -> None: + """Claim a QUEUED task -> WORKING.""" + _assert_state(node, "QUEUED", "claim") + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + with FileLock(self._inbox_path): + self._ws.set_property(node, "STARTED", now_str) + self._ws.transition(node, "WORKING") + self._ws.save(self._inbox_path) + + def complete( + self, + node: NodeView, + tokens_used: int = 0, + cost: str = "", + result_path: str = "", + quality_score: float | None = None, + ) -> None: + """Complete a WORKING task -> DONE.""" + _assert_state(node, "WORKING", "complete") + now_str = datetime.now().strftime("[%Y-%m-%d %a %H:%M]") + with FileLock(self._inbox_path): + self._ws.set_property(node, "COMPLETED", now_str) + if tokens_used: + self._ws.set_property(node, "TOKENS_USED", str(tokens_used)) + if cost: + self._ws.set_property(node, "COST", cost) + if result_path: + self._ws.set_property(node, "RESULT_PATH", result_path) + if quality_score is not None: + self._ws.set_property( + node, "QUALITY_SCORE", f"{quality_score:.2f}" + ) + self._ws.transition(node, "DONE") + self._ws.save(self._inbox_path) + + def retry(self, node: NodeView, reason: str = "") -> None: + """Return a WORKING task -> QUEUED after failure.""" + _assert_state(node, "WORKING", "retry") + retry_count = int(node.properties.get("RETRY_COUNT", "0")) + 1 + with FileLock(self._inbox_path): + self._ws.set_property(node, "RETRY_COUNT", str(retry_count)) + if reason: + self._ws.set_property(node, "RETRY_REASON", reason) + self._ws.transition(node, "QUEUED") + self._ws.save(self._inbox_path) + + def request_revision(self, node: NodeView, feedback: str = "") -> None: + """Return a DONE task -> QUEUED for revision. + + This works because DONE is an active state in task_state_config. + """ + _assert_state(node, "DONE", "request_revision") + with FileLock(self._inbox_path): + if feedback: + self._ws.set_property(node, "REVISION_FEEDBACK", feedback) + self._ws.transition(node, "QUEUED") + self._ws.save(self._inbox_path) + + def find_by_state(self, *states: str) -> list[NodeView]: + """Find tasks by state.""" + self._reload() + return self._ws.find_by_state(*states) + + def find_awaiting_approval(self) -> list[NodeView]: + """Find tasks awaiting owner approval.""" + self._reload() + return [ + n + for n in self._ws.find_by_state("WAITING") + if n.properties.get("AWAITING") == "owner-approval" + ] + + def counts(self) -> dict[str, int]: + """Get task counts by state.""" + self._reload() + all_nodes = list(self._ws.all_nodes()) + # Skip root node (the file itself) + nodes = [n for n in all_nodes if n.level > 0] + result = { + "queued": sum(1 for n in nodes if n.todo == "QUEUED"), + "working": sum(1 for n in nodes if n.todo == "WORKING"), + "done": sum(1 for n in nodes if n.todo == "DONE"), + "waiting": sum(1 for n in nodes if n.todo == "WAITING"), + "cancelled": sum(1 for n in nodes if n.todo == "CANCELLED"), + "total": len(nodes), + } + return result + + @property + def workspace(self) -> OrgWorkspace: + return self._ws +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_agent_inbox.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/agent_inbox.py tests/test_agent_inbox.py +git commit -m "feat: add agent inbox — per-agent task store with state machine lifecycle" +``` + +--- + +## Chunk 3: Task Governor + +### Task 5: Implement task governor (trust, budgets, rate limits) + +**Files:** +- Create: `lib/governor.py` +- Create: `tests/test_governor.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_governor.py +import json +import time +from datetime import datetime +from pathlib import Path + +import pytest + + +@pytest.fixture +def gov(tmp_path, monkeypatch): + from lib.governor import TaskGovernor + + monkeypatch.setenv("DATACORE_ROOT", str(tmp_path)) + state_dir = tmp_path / ".datacore" / "state" / "messaging" + state_dir.mkdir(parents=True) + return TaskGovernor(state_dir=state_dir) + + +class TestTrustTierResolution: + def test_unknown_actor_gets_unknown_tier(self, gov): + result = gov.check_task("random@example.com", estimated_tokens=1000) + assert result.trust_tier == "unknown" + assert result.auto_accept is False + + def test_budget_check_within_limit(self, gov): + result = gov.check_task("user@x.com", estimated_tokens=5000) + assert result.allowed is True + + def test_budget_check_exceeds_per_sender(self, gov): + # Unknown tier has 10_000 daily limit + for i in range(3): + gov.record_usage("spammer@x.com", tokens=4000) + result = gov.check_task("spammer@x.com", estimated_tokens=4000) + assert result.allowed is False + assert "budget" in result.reason.lower() + + +class TestRateLimiting: + def test_tasks_per_hour_limit(self, gov): + # Unknown tier gets 5 tasks/hour default + for i in range(5): + gov.record_task_submission("spammer@x.com") + result = gov.check_task("spammer@x.com", estimated_tokens=100) + assert result.allowed is False + assert "rate" in result.reason.lower() + + +class TestBudgetTracking: + def test_record_and_query_usage(self, gov): + gov.record_usage("user@x.com", tokens=5000) + gov.record_usage("user@x.com", tokens=3000) + usage = gov.get_usage("user@x.com") + assert usage["tokens_today"] == 8000 + + def test_usage_persists_to_file(self, gov): + gov.record_usage("user@x.com", tokens=5000) + # Create new governor instance (simulates restart) + from lib.governor import TaskGovernor + + gov2 = TaskGovernor(state_dir=gov._state_dir) + usage = gov2.get_usage("user@x.com") + assert usage["tokens_today"] == 5000 + + def test_daily_budget_summary(self, gov): + gov.record_usage("a@x.com", tokens=5000) + gov.record_usage("b@x.com", tokens=3000) + summary = gov.daily_summary() + assert summary["total_tokens"] == 8000 + assert len(summary["by_sender"]) == 2 + + +class TestEffortCheck: + def test_effort_within_tier_limit(self, gov): + # Unknown tier max_task_effort = 3 + result = gov.check_task( + "unknown@x.com", estimated_tokens=100, effort=3 + ) + assert result.allowed is True + + def test_effort_exceeds_tier_limit(self, gov): + result = gov.check_task( + "unknown@x.com", estimated_tokens=100, effort=5 + ) + assert result.allowed is False + assert "effort" in result.reason.lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_governor.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement lib/governor.py** + +```python +# lib/governor.py +"""Task governance — trust tiers, compute budgets, rate limits. + +Controls what work Claude agents accept and at what cost. +Per DIP-0023 Section 7. +""" + +import json +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from org_workspace.concurrency import FileLock + +from lib.config import ( + get_trust_tier, + get_trust_tier_config, + get_compute_config, +) + + +@dataclass +class TaskCheckResult: + """Result of a task governance check.""" + + allowed: bool + trust_tier: str + auto_accept: bool + reason: str = "" + priority_boost: float = 1.0 + + +@dataclass +class _SenderState: + tokens_today: int = 0 + tasks_this_hour: list[float] = field(default_factory=list) + tasks_today: int = 0 + active_tasks: int = 0 # Currently QUEUED or WORKING (not completed) + + +class TaskGovernor: + """Enforces trust tiers, budgets, and rate limits.""" + + def __init__(self, state_dir: Path | None = None): + self._state_dir = state_dir or Path(".datacore/state/messaging") + self._state_dir.mkdir(parents=True, exist_ok=True) + self._today = datetime.now().strftime("%Y-%m-%d") + self._budget_file = self._state_dir / f"budget-{self._today}.json" + self._state: dict[str, _SenderState] = {} + self._load_state() + + def _load_state(self) -> None: + with FileLock(self._budget_file): + if self._budget_file.exists(): + try: + data = json.loads(self._budget_file.read_text()) + for sender, info in data.get("senders", {}).items(): + self._state[sender] = _SenderState( + tokens_today=info.get("tokens_today", 0), + tasks_this_hour=info.get("tasks_this_hour", []), + tasks_today=info.get("tasks_today", 0), + active_tasks=info.get("active_tasks", 0), + ) + except (json.JSONDecodeError, KeyError): + pass + + def _save_state(self) -> None: + now = time.time() + hour_ago = now - 3600 + data = { + "date": self._today, + "senders": {}, + } + for sender, state in self._state.items(): + # Prune stale rate-limit entries on save + state.tasks_this_hour = [t for t in state.tasks_this_hour if t > hour_ago] + data["senders"][sender] = { + "tokens_today": state.tokens_today, + "tasks_this_hour": state.tasks_this_hour, + "tasks_today": state.tasks_today, + "active_tasks": state.active_tasks, + } + with FileLock(self._budget_file): + self._budget_file.write_text(json.dumps(data, indent=2)) + + def _check_date_rollover(self) -> None: + """Reset state if day has changed (for long-lived processes).""" + today = datetime.now().strftime("%Y-%m-%d") + if today != self._today: + self._today = today + self._budget_file = self._state_dir / f"budget-{today}.json" + self._state = {} + self._load_state() + + def _get_sender(self, actor_id: str) -> _SenderState: + if actor_id not in self._state: + self._state[actor_id] = _SenderState() + return self._state[actor_id] + + def check_task( + self, + actor_id: str, + estimated_tokens: int = 0, + effort: int = 0, + ) -> TaskCheckResult: + """Check if a task from this actor should be accepted.""" + self._check_date_rollover() + tier_name = get_trust_tier(actor_id) + tier_config = get_trust_tier_config(tier_name) + compute = get_compute_config() + sender = self._get_sender(actor_id) + + auto_accept = tier_config.get("auto_accept", False) + boost = tier_config.get("priority_boost", 1.0) + + # Check effort limit + max_effort = tier_config.get("max_task_effort", 0) + if max_effort > 0 and effort > max_effort: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Effort {effort} exceeds tier limit {max_effort}", + priority_boost=boost, + ) + + # Check per-sender daily token budget + daily_limit = tier_config.get("daily_token_limit", 0) + if daily_limit > 0: + if sender.tokens_today + estimated_tokens > daily_limit: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Budget exceeded: {sender.tokens_today}/{daily_limit} tokens today", + priority_boost=boost, + ) + + # Check global daily budget + global_limit = compute.get("daily_budget_tokens", 0) + if global_limit > 0: + total = sum(s.tokens_today for s in self._state.values()) + if total + estimated_tokens > global_limit: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason="Global daily budget exceeded", + priority_boost=boost, + ) + + # Check rate limit (tasks per hour) + now = time.time() + hour_ago = now - 3600 + recent = [t for t in sender.tasks_this_hour if t > hour_ago] + rate_limits = compute.get("rate_limits", {}) + tasks_per_hour = rate_limits.get("tasks_per_hour", 5) + if len(recent) >= tasks_per_hour: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Rate limit: {len(recent)}/{tasks_per_hour} tasks this hour", + priority_boost=boost, + ) + + # Check queue depth (active tasks only, not total submissions) + max_queue = compute.get("max_queue_depth", 20) + active_tasks = sum(s.active_tasks for s in self._state.values()) + if active_tasks >= max_queue: + return TaskCheckResult( + allowed=False, + trust_tier=tier_name, + auto_accept=auto_accept, + reason=f"Queue full: {active_tasks}/{max_queue}", + priority_boost=boost, + ) + + return TaskCheckResult( + allowed=True, + trust_tier=tier_name, + auto_accept=auto_accept, + priority_boost=boost, + ) + + def check_and_record( + self, + actor_id: str, + estimated_tokens: int = 0, + effort: int = 0, + ) -> TaskCheckResult: + """Check + record under a single lock — eliminates TOCTOU. + + If the check passes, immediately records the submission. + Both operations run within the same FileLock scope. + Uses _write_state (no lock) since we already hold the lock. + """ + with FileLock(self._budget_file): + # Reload fresh state under lock + if self._budget_file.exists(): + try: + data = json.loads(self._budget_file.read_text()) + for sender, info in data.get("senders", {}).items(): + self._state[sender] = _SenderState( + tokens_today=info.get("tokens_today", 0), + tasks_this_hour=info.get("tasks_this_hour", []), + tasks_today=info.get("tasks_today", 0), + active_tasks=info.get("active_tasks", 0), + ) + except (json.JSONDecodeError, KeyError): + pass + + result = self.check_task(actor_id, estimated_tokens, effort) + if result.allowed: + sender = self._get_sender(actor_id) + sender.tasks_this_hour.append(time.time()) + sender.tasks_today += 1 + sender.active_tasks += 1 + # Write state directly (no nested FileLock) + now = time.time() + hour_ago = now - 3600 + out = {"date": self._today, "senders": {}} + for s_id, s in self._state.items(): + s.tasks_this_hour = [t for t in s.tasks_this_hour if t > hour_ago] + out["senders"][s_id] = { + "tokens_today": s.tokens_today, + "tasks_this_hour": s.tasks_this_hour, + "tasks_today": s.tasks_today, + "active_tasks": s.active_tasks, + } + self._budget_file.write_text(json.dumps(out, indent=2)) + return result + + def record_usage(self, actor_id: str, tokens: int) -> None: + """Record token usage for a sender.""" + self._check_date_rollover() + sender = self._get_sender(actor_id) + sender.tokens_today += tokens + self._save_state() + + def record_task_submission(self, actor_id: str) -> None: + """Record a task submission (for rate limiting).""" + sender = self._get_sender(actor_id) + sender.tasks_this_hour.append(time.time()) + sender.tasks_today += 1 + sender.active_tasks += 1 + self._save_state() + + def record_task_completion(self, actor_id: str) -> None: + """Record a task completion (decrements active count).""" + sender = self._get_sender(actor_id) + sender.active_tasks = max(0, sender.active_tasks - 1) + self._save_state() + + def get_usage(self, actor_id: str) -> dict: + """Get usage stats for a sender.""" + sender = self._get_sender(actor_id) + return { + "tokens_today": sender.tokens_today, + "tasks_today": sender.tasks_today, + } + + def daily_summary(self) -> dict: + """Get daily budget summary across all senders.""" + total_tokens = sum(s.tokens_today for s in self._state.values()) + by_sender = { + actor: {"tokens": s.tokens_today, "tasks": s.tasks_today} + for actor, s in self._state.items() + } + return { + "date": self._today, + "total_tokens": total_tokens, + "by_sender": by_sender, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_governor.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/governor.py tests/test_governor.py +git commit -m "feat: add task governor — trust tiers, per-sender budgets, rate limiting" +``` + +--- + +## Chunk 4: Relay Consolidation and Bug Fixes + +### Task 6: Consolidate relay to single file and fix critical bugs + +**Files:** +- Create: `lib/relay.py` (consolidated from 3 sources) +- Delete: `lib/datacore-msg-relay.py` +- Delete: `relay/datacore-msg-relay.py` +- Modify: `relay/Dockerfile` +- Create: `tests/test_relay.py` + +- [ ] **Step 1: Write failing tests for relay fixes** + +```python +# tests/test_relay.py +import json + +import pytest +from aiohttp import web +from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop + + +@pytest.fixture +def relay_app(): + from lib.relay import create_relay_app + + return create_relay_app(relay_secret="test-secret-123") + + +class TestRelayAuth: + @pytest.mark.asyncio + async def test_status_requires_no_user_list(self, relay_app, aiohttp_client): + """Status endpoint must NOT leak connected usernames.""" + client = await aiohttp_client(relay_app) + resp = await client.get("/status") + data = await resp.json() + assert resp.status == 200 + assert "users" not in data + assert "users_online" not in data + assert data["status"] == "ok" + + @pytest.mark.asyncio + async def test_status_shows_count_only(self, relay_app, aiohttp_client): + client = await aiohttp_client(relay_app) + resp = await client.get("/status") + data = await resp.json() + assert "connected" in data + assert isinstance(data["connected"], int) + + +class TestRelayStartup: + def test_relay_refuses_empty_secret(self): + from lib.relay import create_relay_app + + with pytest.raises(ValueError, match="RELAY_SECRET"): + create_relay_app(relay_secret="") + + def test_relay_accepts_valid_secret(self): + from lib.relay import create_relay_app + + app = create_relay_app(relay_secret="valid-secret") + assert app is not None + + +class TestArgParsing: + def test_host_flag_not_h(self): + """Verify -h is NOT used for hosting (it's --help).""" + from lib.relay import parse_relay_args + + # --host should work + args = parse_relay_args(["--host"]) + assert args.host is True + + # -h should trigger help, not hosting + with pytest.raises(SystemExit): + parse_relay_args(["-h"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_relay.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement lib/relay.py (consolidated)** + +```python +# lib/relay.py +"""WebSocket relay server — single consolidated implementation. + +Replaces the three duplicate relay files (lib/, relay/, embedded in GUI). +Fixes: auth leak on /status, -h flag collision, empty secret startup. +""" + +import argparse +import json +import logging +import os +import time +from dataclasses import dataclass, field + +from aiohttp import web, WSMsgType + +logger = logging.getLogger(__name__) + + +@dataclass +class User: + username: str + ws: web.WebSocketResponse + connected_at: float = field(default_factory=time.time) + status: str = "online" + + +class RelayServer: + def __init__(self, secret: str, claude_whitelist: dict | None = None): + self.secret = secret + self.users: dict[str, User] = {} + self.claude_whitelist = claude_whitelist or {} + + def add_user(self, username: str, ws: web.WebSocketResponse) -> None: + self.users[username] = User(username=username, ws=ws) + + def remove_user(self, username: str) -> None: + self.users.pop(username, None) + + def list_users(self) -> list[str]: + return list(self.users.keys()) + + def connected_count(self) -> int: + return len(self.users) + + async def route_message(self, msg: dict, sender: str) -> bool: + target = msg.get("to", "") + if not target or target not in self.users: + return False + try: + await self.users[target].ws.send_json(msg) + return True + except Exception: + logger.warning("Failed to deliver message to %s", target) + return False + + async def broadcast_presence(self, username: str, status: str) -> None: + event = { + "type": "presence", + "username": username, + "status": status, + } + for user in self.users.values(): + if user.username != username: + try: + await user.ws.send_json(event) + except Exception: + pass + + +def create_relay_app(relay_secret: str) -> web.Application: + """Create the aiohttp relay application. + + Raises ValueError if relay_secret is empty. + """ + if not relay_secret: + raise ValueError( + "RELAY_SECRET must be set. " + "Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'" + ) + + relay = RelayServer(secret=relay_secret) + app = web.Application() + app["relay"] = relay + + async def handle_status(request: web.Request) -> web.Response: + """Status endpoint — shows connected count only, never usernames.""" + return web.json_response( + {"status": "ok", "connected": relay.connected_count()} + ) + + async def handle_ws(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + username = None + + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + data = json.loads(msg.data) + except json.JSONDecodeError: + continue + + msg_type = data.get("type") + + if msg_type == "auth": + if data.get("secret") != relay.secret: + await ws.send_json( + {"type": "error", "message": "Invalid secret"} + ) + await ws.close() + return ws + username = data.get("username", "") + if not username: + await ws.send_json( + {"type": "error", "message": "Username required"} + ) + await ws.close() + return ws + relay.add_user(username, ws) + await ws.send_json({"type": "auth_ok"}) + await relay.broadcast_presence(username, "online") + logger.info("User connected: %s", username) + + elif msg_type == "send" and username: + payload = { + "type": "message", + "from": username, + "to": data.get("to"), + "content": data.get("content", ""), + "id": data.get("id", ""), + "thread": data.get("thread"), + "reply_to": data.get("reply_to"), + "timestamp": data.get( + "timestamp", time.strftime("%Y-%m-%dT%H:%M:%S") + ), + } + delivered = await relay.route_message(payload, username) + if not delivered: + await ws.send_json( + { + "type": "error", + "message": f"User {data.get('to')} not online", + } + ) + + elif msg_type == "status_change" and username: + new_status = data.get("status", "online") + if username in relay.users: + relay.users[username].status = new_status + await relay.broadcast_presence(username, new_status) + + elif msg_type == "ping": + await ws.send_json({"type": "pong"}) + + elif msg.type == WSMsgType.ERROR: + logger.error("WS error: %s", ws.exception()) + finally: + if username: + relay.remove_user(username) + await relay.broadcast_presence(username, "offline") + logger.info("User disconnected: %s", username) + + return ws + + app.router.add_get("/status", handle_status) + app.router.add_get("/ws", handle_ws) + return app + + +def parse_relay_args(args: list[str] | None = None) -> argparse.Namespace: + """Parse relay CLI arguments. Uses --host (not -h) for hosting.""" + parser = argparse.ArgumentParser(description="Datacore messaging relay") + parser.add_argument("--host", action="store_true", help="Host relay server") + parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)") + parser.add_argument("--bind", default="0.0.0.0", help="Bind address") + return parser.parse_args(args) + + +def run_relay() -> None: + """Entry point for standalone relay server.""" + args = parse_relay_args() + secret = os.environ.get("RELAY_SECRET", "") + app = create_relay_app(relay_secret=secret) + web.run_app(app, host=args.bind, port=args.port) + + +if __name__ == "__main__": + run_relay() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_relay.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Delete duplicate relay files and update Dockerfile** + +Delete `lib/datacore-msg-relay.py` and `relay/datacore-msg-relay.py`. + +Update `relay/Dockerfile` to use: +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY lib/ lib/ +CMD ["python", "-m", "lib.relay", "--host"] +``` + +Update `Procfile` to: +``` +web: python -m lib.relay --host +``` + +- [ ] **Step 6: Commit** + +```bash +git add lib/relay.py tests/test_relay.py relay/Dockerfile Procfile +git rm lib/datacore-msg-relay.py relay/datacore-msg-relay.py +git commit -m "feat: consolidate relay to single lib/relay.py — fix auth leak, -h collision, empty secret" +``` + +--- + +## Chunk 5: Rewire Hooks and Update Module Manifest + +### Task 7: Rewire hooks to use lib modules + +**Files:** +- Rewrite: `hooks/inbox-watcher.py` +- Rewrite: `hooks/send-reply.py` +- Rewrite: `hooks/mark-message.py` +- Rewrite: `hooks/task-queue.py` +- Create: `tests/test_hooks.py` + +Each hook currently has duplicated settings/parsing code. Rewire them to import from `lib/` modules. + +- [ ] **Step 1: Write failing tests for hooks** + +```python +# tests/test_hooks.py +"""Tests for hooks using the new lib modules. + +Hooks are tested by importing their main functions directly. +Each hook uses sys.path setup from lib/__init__.py. +""" +import sys +from pathlib import Path + +import pytest + +# Ensure repo root is on sys.path for hook imports +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _fresh_config(): + clear_settings_cache() + yield + clear_settings_cache() + + +class TestInboxWatcher: + def test_no_tasks_returns_empty(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + queued = inbox.find_by_state("QUEUED") + working = inbox.find_by_state("WORKING") + assert len(queued) == 0 + assert len(working) == 0 + + def test_claims_next_queued_task(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + inbox.create_task("owner@x.com", "Research AI", "owner", ["AI"]) + queued = inbox.find_by_state("QUEUED") + assert len(queued) == 1 + + inbox.claim(queued[0]) + assert queued[0].todo == "WORKING" + assert len(inbox.find_by_state("QUEUED")) == 0 + + def test_skips_when_task_already_working( + self, tmp_space, task_state_config, monkeypatch + ): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + t1 = inbox.create_task("owner@x.com", "Task 1", "owner", ["AI"]) + inbox.create_task("owner@x.com", "Task 2", "owner", ["AI"]) + inbox.claim(t1) + + # When one task is WORKING, watcher should not claim another + working = inbox.find_by_state("WORKING") + assert len(working) == 1 + + +class TestSendReply: + def test_creates_outgoing_message(self, tmp_space, msg_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.message_store import MessageStore + + store = MessageStore(tmp_space, msg_state_config) + msg = store.create_message("gregor@x.com", "tex@x.com", "Hello!") + assert msg.todo == "TODO" + assert msg.properties["FROM"] == "gregor@x.com" + + +class TestMarkMessage: + def test_mark_read_removes_unread(self, tmp_space, msg_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.message_store import MessageStore + + store = MessageStore(tmp_space, msg_state_config) + msg = store.create_message("a@x.com", "b@x.com", "test") + assert "unread" in msg.shallow_tags + store.mark_read(msg) + assert "unread" not in msg.shallow_tags + assert msg.todo == "DONE" + + +class TestTaskQueue: + def test_counts_reflect_state(self, tmp_space, task_state_config, monkeypatch): + monkeypatch.setenv("DATACORE_ROOT", str(tmp_space.parent)) + from lib.agent_inbox import AgentInbox + + inbox = AgentInbox(tmp_space, "test-claude", task_state_config) + inbox.create_task("owner@x.com", "T1", "owner", ["AI"]) + inbox.create_task("unknown@x.com", "T2", "unknown", ["AI"]) + counts = inbox.counts() + assert counts["queued"] == 1 + assert counts["waiting"] == 1 + assert counts["total"] == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_hooks.py -v` +Expected: PASS (these test lib modules, which should already work) + +- [ ] **Step 3: Rewrite hooks/inbox-watcher.py** + +```python +#!/usr/bin/env python3 +"""Inbox watcher hook — checks for queued tasks and claims the next one. + +Claude Code hook: runs on prompt_submit to inject task context. +Uses lib/ modules instead of raw file parsing. +""" + +import sys +from pathlib import Path + +# Ensure lib/ is importable +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import get_username, get_default_space, datacore_root +from lib.agent_inbox import AgentInbox + + +def main() -> None: + username = get_username() + space = get_default_space() + space_root = datacore_root() / space + + inbox = AgentInbox(space_root, f"{username}-claude") + + # Only one task at a time + working = inbox.find_by_state("WORKING") + if working: + task = working[0] + print(f"[messaging] Task in progress: {task.heading}") + return + + queued = inbox.find_by_state("QUEUED") + if not queued: + return # Nothing to do + + # Claim the oldest queued task + task = queued[0] + inbox.claim(task) + print(f"[messaging] Claimed task: {task.heading}") + print(f" From: {task.properties.get('FROM', 'unknown')}") + print(f" Tier: {task.properties.get('TRUST_TIER', 'unknown')}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Rewrite hooks/send-reply.py** + +```python +#!/usr/bin/env python3 +"""Send reply hook — creates outgoing messages and completes agent tasks. + +Uses lib/ modules instead of raw file parsing. +""" + +import sys +import argparse +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import get_username, get_default_space, datacore_root +from lib.message_store import MessageStore +from lib.agent_inbox import AgentInbox + + +def main() -> None: + parser = argparse.ArgumentParser(description="Send a reply") + parser.add_argument("to", help="Recipient actor ID") + parser.add_argument("content", help="Message content") + parser.add_argument("--reply-to", help="Message ID to reply to") + parser.add_argument("--complete-task", help="Task ID to mark complete") + parser.add_argument("--tokens", type=int, default=0, help="Tokens used") + args = parser.parse_args() + + username = get_username() + space = get_default_space() + space_root = datacore_root() / space + + # Send the message + store = MessageStore(space_root) + msg = store.create_message( + from_actor=username, + to_actor=args.to, + content=args.content, + reply_to=args.reply_to, + ) + print(f"[messaging] Sent: {msg.properties['ID']}") + + # Optionally complete the associated task + if args.complete_task: + inbox = AgentInbox(space_root, f"{username}-claude") + node = inbox.workspace.find_by_id(args.complete_task) + if node and node.todo == "WORKING": + inbox.complete(node, tokens_used=args.tokens) + print(f"[messaging] Completed task: {args.complete_task}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 5: Rewrite hooks/mark-message.py** + +```python +#!/usr/bin/env python3 +"""Mark message hook — mark messages as read or archived. + +Uses lib/message_store instead of regex-based tag manipulation. +""" + +import sys +import argparse +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import get_default_space, datacore_root +from lib.message_store import MessageStore + + +def main() -> None: + parser = argparse.ArgumentParser(description="Mark message state") + parser.add_argument("msg_id", help="Message ID") + parser.add_argument("action", choices=["read", "archive"], help="Action") + args = parser.parse_args() + + space = get_default_space() + space_root = datacore_root() / space + + store = MessageStore(space_root) + node = store.find_by_id(args.msg_id) + if not node: + print(f"[messaging] Message not found: {args.msg_id}") + return + + if args.action == "read": + store.mark_read(node) + print(f"[messaging] Marked read: {args.msg_id}") + elif args.action == "archive": + store.archive(node) + print(f"[messaging] Archived: {args.msg_id}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 6: Rewrite hooks/task-queue.py** + +```python +#!/usr/bin/env python3 +"""Task queue hook — query and manage agent task queue. + +Uses lib/agent_inbox instead of JSON state file. +""" + +import sys +import argparse +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from lib.config import get_username, get_default_space, datacore_root +from lib.agent_inbox import AgentInbox + + +def main() -> None: + parser = argparse.ArgumentParser(description="Task queue management") + parser.add_argument( + "action", + choices=["status", "approve", "reject", "cancel"], + help="Queue action", + ) + parser.add_argument("--task-id", help="Task ID (for approve/reject/cancel)") + parser.add_argument("--reason", default="", help="Reason (for reject/cancel)") + args = parser.parse_args() + + username = get_username() + space = get_default_space() + space_root = datacore_root() / space + + inbox = AgentInbox(space_root, f"{username}-claude") + + if args.action == "status": + counts = inbox.counts() + print(f"Task queue for {username}-claude:") + print(f" Queued: {counts['queued']}") + print(f" Working: {counts['working']}") + print(f" Waiting: {counts['waiting']}") + print(f" Done: {counts['done']}") + print(f" Total: {counts['total']}") + + elif args.action == "approve" and args.task_id: + node = inbox.workspace.find_by_id(args.task_id) + if node: + inbox.approve(node) + print(f"[messaging] Approved: {args.task_id}") + + elif args.action == "reject" and args.task_id: + node = inbox.workspace.find_by_id(args.task_id) + if node: + inbox.reject(node, reason=args.reason) + print(f"[messaging] Rejected: {args.task_id}") + + elif args.action == "cancel" and args.task_id: + node = inbox.workspace.find_by_id(args.task_id) + if node: + inbox.cancel(node, reason=args.reason) + print(f"[messaging] Cancelled: {args.task_id}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 7: Run hook tests** + +Run: `pytest tests/test_hooks.py -v` +Expected: All tests PASS + +- [ ] **Step 8: Smoke test hooks** + +```bash +python hooks/task-queue.py status 2>&1 | head -5 +``` +Expected: Prints queue counts (may show 0s if no tasks) + +- [ ] **Step 9: Commit** + +```bash +git add hooks/ tests/test_hooks.py +git commit -m "refactor: rewire all hooks to use lib modules — remove duplicated code, use org-workspace" +``` + +--- + +### Task 8: Update module manifest and agent docs + +**Files:** +- Modify: `module.yaml` +- Rewrite: `agents/claude-inbox.md` → `agents/message-task-intake.md` +- Modify: `agents/message-digest.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update module.yaml** + +Key changes: +- Version bump to `0.2.0` +- Update agent list (`claude-inbox` → `message-task-intake`) +- Fix hooks to point to Python files (not `.md` files) +- Add `inbox_feeds` configuration +- Update dependency to include `org-workspace>=0.3.0` + +- [ ] **Step 2: Rename and rewrite claude-inbox agent** + +Rename `agents/claude-inbox.md` to `agents/message-task-intake.md`. +Rewrite to match actual code: +- References `org/messaging/agents/{username}-claude.org` (not `claude.org`) +- Uses org-workspace state machine (WAITING/QUEUED/WORKING/DONE) +- References TaskGovernor for approval gates +- Correct agent routing (matches actual tag patterns) + +- [ ] **Step 3: Update message-digest.md** + +Fix references to match new file layout: +- `org/messaging/inbox.org` instead of `org/inboxes/` +- Use org-workspace query API description +- Reference Universal Inbox feed + +- [ ] **Step 4: Update CLAUDE.md** + +Rewrite to match new architecture: +- Message storage at `org/messaging/` (not `org/inboxes/`) +- `TODO` keyword with `:message:` tag (not `MESSAGE` keyword) +- Agent inbox at `org/messaging/agents/` +- Trust tiers and governance +- `contacts.yaml` (not `USERS.yaml`) +- Correct settings schema + +- [ ] **Step 5: Commit** + +```bash +git add module.yaml agents/ CLAUDE.md +git rm agents/claude-inbox.md +git commit -m "docs: update module manifest, agent specs, and CLAUDE.md to match v0.2.0 architecture" +``` + +--- + +## Chunk 6: Integration Test and Cleanup + +### Task 9: Integration test — end-to-end message flow + +**Files:** +- Create: `tests/test_integration.py` + +- [ ] **Step 1: Write integration test** + +```python +# tests/test_integration.py +"""End-to-end test: message creation, agent task lifecycle, governance.""" + +import pytest +from pathlib import Path + +from lib.config import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _fresh_config(): + clear_settings_cache() + yield + clear_settings_cache() + + +@pytest.fixture +def full_setup(tmp_space, msg_state_config, task_state_config): + """Set up message store, agent inbox, and governor together.""" + from lib.message_store import MessageStore + from lib.agent_inbox import AgentInbox + from lib.governor import TaskGovernor + + state_dir = tmp_space / ".datacore" / "state" / "messaging" + state_dir.mkdir(parents=True) + + return { + "store": MessageStore(tmp_space, state_config=msg_state_config), + "agent": AgentInbox(tmp_space, "test-claude", state_config=task_state_config), + "governor": TaskGovernor(state_dir=state_dir), + "space": tmp_space, + } + + +class TestMessageToTaskFlow: + def test_unknown_sender_needs_approval(self, full_setup): + """An unknown sender's task goes to WAITING for owner approval.""" + agent = full_setup["agent"] + gov = full_setup["governor"] + + # Governor allows (no budget exceeded) but tier = unknown + check = gov.check_task("tex@team.com", estimated_tokens=5000) + assert check.allowed is True + assert check.trust_tier == "unknown" # Not configured as team + + # Create task with the resolved tier — unknown needs approval + task = agent.create_task( + from_actor="tex@team.com", + content="Research competitors", + trust_tier=check.trust_tier, + tags=["AI", "research"], + ) + assert task.todo == "WAITING" + + def test_full_task_lifecycle(self, full_setup): + """Owner task: auto-accept -> claim -> complete -> archive.""" + agent = full_setup["agent"] + gov = full_setup["governor"] + + check = gov.check_task("owner@x.com", estimated_tokens=1000) + # Pass "owner" directly — in production, tier comes from settings + task = agent.create_task( + from_actor="owner@x.com", + content="Quick research", + trust_tier="owner", + tags=["AI"], + ) + assert task.todo == "QUEUED" + + agent.claim(task) + assert task.todo == "WORKING" + + agent.complete(task, tokens_used=800) + assert task.todo == "DONE" + + gov.record_usage("owner@x.com", tokens=800) + usage = gov.get_usage("owner@x.com") + assert usage["tokens_today"] == 800 + + +class TestGovernorBlocksAbuse: + def test_rate_limited_sender_blocked(self, full_setup): + agent = full_setup["agent"] + gov = full_setup["governor"] + + # Submit 5 tasks (unknown tier limit) + for i in range(5): + gov.record_task_submission("spammer@x.com") + + # 6th should be blocked + check = gov.check_task("spammer@x.com", estimated_tokens=100) + assert check.allowed is False + + +class TestMessageStoreIndependence: + def test_messages_and_tasks_coexist(self, full_setup): + """Messages in inbox.org and tasks in agent/*.org don't interfere.""" + store = full_setup["store"] + agent = full_setup["agent"] + + store.create_message("a@x.com", "b@x.com", "Hello") + agent.create_task("a@x.com", "Do work", "owner", ["AI"]) + + assert len(store.find_unread()) == 1 + assert len(agent.find_by_state("QUEUED")) == 1 +``` + +- [ ] **Step 2: Run all tests** + +Run: `pytest tests/ -v` +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_integration.py +git commit -m "test: add integration tests — message-to-task flow, governance blocks, store independence" +``` + +--- + +### Task 10: Cleanup and PR preparation + +**Files:** +- Modify: `README.md` +- Delete: `lib/datacore-msg-window.py` (legacy GUI, superseded by `datacore-msg.py`) +- Delete: `templates/USERS.yaml` (replaced by contacts.yaml) +- Create: `templates/contacts.yaml` + +- [ ] **Step 1: Create contacts.yaml template** + +```yaml +# Known ActivityPub actors for this space +# Add actors via /msg-trust or manually edit this file +actors: [] +# Example: +# - id: "tex@team.example.com" +# name: "Tex" +# trust_tier: team +# added: 2026-03-11 +``` + +- [ ] **Step 2: Delete legacy files** + +```bash +git rm lib/datacore-msg-window.py templates/USERS.yaml +``` + +- [ ] **Step 3: Update README.md** + +Update to reflect v0.2.0 changes: +- New storage layout (`org/messaging/`) +- org-workspace dependency +- Task governance overview +- Agent inbox concept +- Updated install instructions (include org-workspace) +- Remove references to single relay URL (configurable now) + +- [ ] **Step 4: Run full test suite one final time** + +Run: `pytest tests/ -v --tb=short` +Expected: All tests PASS, 0 errors + +- [ ] **Step 5: Commit and prepare PR** + +```bash +git add templates/contacts.yaml README.md +git commit -m "chore: cleanup — remove legacy files, add contacts template, update README for v0.2.0" +``` + +Final PR should contain: +- 10 commits, logically organized +- All CRITICAL and HIGH audit issues fixed +- org-workspace integration with FileLock +- Agent inbox with full state machine +- Task governor with trust tiers, budgets, rate limits +- Consolidated relay (1 file, not 3) +- Tests for all new modules +- Updated docs matching actual code