alter-runtime 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,457 @@
1
+ """GitWatcher - ambient signal adapter for git repository activity.
2
+
3
+ Observes one or more local git repositories and publishes ``local.signal``
4
+ events for commits and branch switches. Uses ``watchdog`` to tail the
5
+ per-branch ref files under ``.git/refs/heads/`` and the symbolic ``.git/HEAD``
6
+ pointer, because those change exactly when the user commits (``refs/heads/<br>``
7
+ is rewritten) or switches branches (``HEAD`` is rewritten).
8
+
9
+ Signals
10
+ -------
11
+
12
+ * ``kind = "git_commit"`` - a branch ref advanced. Payload::
13
+
14
+ {
15
+ "repo": "/abs/path/to/repo",
16
+ "branch": "main",
17
+ "sha": "abc123...",
18
+ "previous": "def456..." | None,
19
+ }
20
+
21
+ * ``kind = "git_branch_switch"`` - the HEAD pointer moved to a different
22
+ branch. Payload::
23
+
24
+ {
25
+ "repo": "/abs/path/to/repo",
26
+ "branch": "feature/foo",
27
+ "previous": "main",
28
+ }
29
+
30
+ Both are published on the ``local.signal`` topic for the eventual egress
31
+ producer. The runtime itself does *not* post them back to the DO - that is
32
+ the egress producer's job in W2.2d.
33
+
34
+ Design notes
35
+ ------------
36
+
37
+ * **One observer per repo.** Each configured repo gets a dedicated watchdog
38
+ ``Observer`` so that a misbehaving filesystem event on one repo does not
39
+ block another. For typical developer machines this is ~1-3 observers.
40
+ * **Debounce on ref changes.** Git writes refs atomically via rename, which
41
+ fires both ``on_created`` and ``on_modified`` in rapid succession. We
42
+ debounce by caching the last-seen SHA per ref and only publishing when it
43
+ changes.
44
+ * **Thread boundary.** ``watchdog`` callbacks run on the observer's own
45
+ thread. We marshal onto the asyncio loop via ``loop.call_soon_threadsafe``
46
+ before publishing to the bus - the bus is *not* thread-safe.
47
+ * **Autodetect CWD.** When ``repo_paths`` is empty and the current working
48
+ directory is a git repo, the adapter watches the CWD. This is the common
49
+ case on a developer laptop where the user runs ``alter-runtime daemon``
50
+ from inside their main workspace.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import asyncio
56
+ import contextlib
57
+ import logging
58
+ import os
59
+ import re as _re
60
+ import threading
61
+ from dataclasses import dataclass, field
62
+ from pathlib import Path
63
+ from typing import Any
64
+
65
+ from alter_runtime.config import DaemonConfig
66
+ from alter_runtime.daemon import Component
67
+ from alter_runtime.subscribers.bus import EventBus
68
+
69
+ __all__ = ["GitWatcher"]
70
+
71
+ logger = logging.getLogger("alter_runtime.adapters.git_watcher")
72
+
73
+ EGRESS_TOPIC: str = "local.signal"
74
+
75
+ #: Branch-name shape gate (Pentest 2026-04-26, MEDIUM). Git itself imposes
76
+ #: tighter rules (see ``check-ref-format(1)``), but a watchdog-driven file
77
+ #: rename can land arbitrary bytes here if an attacker plants a malicious
78
+ #: file under ``.git/refs/heads/``. We accept the conservative subset that
79
+ #: covers every legitimate branch name we expect on disk and reject the
80
+ #: rest before they reach the bus and any downstream consumers (DO ingest,
81
+ #: status-bar widgets, etc.).
82
+
83
+ _BRANCH_NAME_RE = _re.compile(r"^[A-Za-z0-9_./-]+$")
84
+
85
+
86
+ def _is_safe_branch_name(branch: str) -> bool:
87
+ """Return True when ``branch`` matches the allowed branch shape.
88
+
89
+ Additional gates beyond the regex (mirroring ``check-ref-format(1)``):
90
+ reject any ``..`` sequence so a planted ref file can't traverse
91
+ upward, reject leading ``/`` / ``-`` / ``.``, and reject trailing
92
+ ``/`` / ``.``.
93
+ """
94
+ if not branch or len(branch) > 255:
95
+ return False
96
+ if not _BRANCH_NAME_RE.match(branch):
97
+ return False
98
+ if ".." in branch:
99
+ return False
100
+ if branch.startswith(("/", "-", ".")):
101
+ return False
102
+ if branch.endswith(("/", ".")):
103
+ return False
104
+ return True
105
+
106
+
107
+ @dataclass
108
+ class _WatchedRepo:
109
+ """Bookkeeping for one watched repository."""
110
+
111
+ path: Path
112
+ git_dir: Path
113
+ #: Last-seen SHAs keyed by branch name, for debouncing redundant fs events.
114
+ branch_shas: dict[str, str] = field(default_factory=dict)
115
+ #: Last-seen HEAD branch name, for detecting branch switches.
116
+ head_branch: str | None = None
117
+ observer: Any | None = None
118
+
119
+
120
+ class GitWatcher(Component):
121
+ """Watches git repos and publishes commit / branch-switch signals.
122
+
123
+ Parameters
124
+ ----------
125
+ config:
126
+ Loaded :class:`DaemonConfig` (currently unused but kept for symmetry
127
+ with the other components and future knobs like ``git_watch_paths``).
128
+ bus:
129
+ Shared :class:`EventBus` - signals are published on ``local.signal``.
130
+ repo_paths:
131
+ Explicit list of repository paths to watch. If empty, the adapter
132
+ autodetects the current working directory when it's a git repo and
133
+ falls back to no-op otherwise.
134
+ """
135
+
136
+ name = "git_watcher"
137
+
138
+ def __init__(
139
+ self,
140
+ config: DaemonConfig,
141
+ bus: EventBus,
142
+ repo_paths: list[Path] | None = None,
143
+ ) -> None:
144
+ self._config = config
145
+ self._bus = bus
146
+ self._explicit_paths = repo_paths
147
+ self._stop_event = asyncio.Event()
148
+ self._loop: asyncio.AbstractEventLoop | None = None
149
+ self._repos: list[_WatchedRepo] = []
150
+
151
+ # ------------------------------------------------------------------
152
+ # Component lifecycle
153
+ # ------------------------------------------------------------------
154
+
155
+ async def run(self) -> None:
156
+ self._loop = asyncio.get_running_loop()
157
+ repos = self._resolve_repos()
158
+ if not repos:
159
+ logger.info("git_watcher no repositories to watch - idle")
160
+ await self._stop_event.wait()
161
+ return
162
+
163
+ try:
164
+ from watchdog.events import FileSystemEventHandler # noqa: F401
165
+ from watchdog.observers import Observer
166
+ except ImportError:
167
+ logger.warning("watchdog not installed - git_watcher disabled")
168
+ await self._stop_event.wait()
169
+ return
170
+
171
+ for repo in repos:
172
+ self._bootstrap_repo_state(repo)
173
+ observer = Observer()
174
+ handler = _GitRefHandler(self, repo)
175
+ refs_dir = repo.git_dir / "refs" / "heads"
176
+ if refs_dir.exists():
177
+ observer.schedule(handler, str(refs_dir), recursive=True)
178
+ head_file = repo.git_dir / "HEAD"
179
+ if head_file.exists():
180
+ observer.schedule(handler, str(repo.git_dir), recursive=False)
181
+ observer.daemon = True
182
+ observer.start()
183
+ repo.observer = observer
184
+ self._repos.append(repo)
185
+ logger.info(
186
+ "git_watcher observing repo=%s initial_branch=%s initial_shas=%s",
187
+ repo.path,
188
+ repo.head_branch,
189
+ {k: v[:7] for k, v in repo.branch_shas.items()},
190
+ )
191
+
192
+ try:
193
+ await self._stop_event.wait()
194
+ finally:
195
+ for repo in self._repos:
196
+ if repo.observer is not None:
197
+ with contextlib.suppress(Exception):
198
+ repo.observer.stop()
199
+ repo.observer.join(timeout=2.0)
200
+ logger.info("git_watcher stopped")
201
+
202
+ async def stop(self) -> None:
203
+ self._stop_event.set()
204
+
205
+ # ------------------------------------------------------------------
206
+ # Repo discovery + initial state
207
+ # ------------------------------------------------------------------
208
+
209
+ def _resolve_repos(self) -> list[_WatchedRepo]:
210
+ paths: list[Path] = []
211
+ if self._explicit_paths:
212
+ paths = [Path(p).expanduser().resolve() for p in self._explicit_paths]
213
+ else:
214
+ # Autodetect from CWD - resolve() so symlinked workspaces don't
215
+ # confuse the symlink check below.
216
+ cwd = Path.cwd().resolve()
217
+ git_dir = cwd / ".git"
218
+ if git_dir.is_dir():
219
+ paths = [cwd]
220
+
221
+ repos: list[_WatchedRepo] = []
222
+ for path in paths:
223
+ git_dir = path / ".git"
224
+ # Pentest 2026-04-26 (MEDIUM): refuse to register a watch when
225
+ # ``.git`` is a symlink. Watchdog follows symlinks transparently,
226
+ # which would let an attacker drop a symlinked .git into a CWD
227
+ # the daemon scans and trick the watcher into observing - and
228
+ # publishing signals from - a directory tree outside the
229
+ # operator's repo set. Worktrees and gitlinks (file ``.git`` with
230
+ # ``gitdir: <path>`` content) are handled separately by git
231
+ # itself; refusing the symlink case is the conservative gate.
232
+ if git_dir.is_symlink():
233
+ logger.warning(
234
+ "git_watcher refusing symlinked .git path=%s -> %s",
235
+ git_dir,
236
+ git_dir.resolve(strict=False),
237
+ )
238
+ continue
239
+ if not git_dir.is_dir():
240
+ logger.warning("git_watcher skipping non-repo path=%s", path)
241
+ continue
242
+ repos.append(_WatchedRepo(path=path, git_dir=git_dir))
243
+ return repos
244
+
245
+ def _bootstrap_repo_state(self, repo: _WatchedRepo) -> None:
246
+ """Prime ``branch_shas`` and ``head_branch`` from current refs.
247
+
248
+ Without this, the first commit after startup would publish a
249
+ ``git_commit`` for every existing ref because the ``branch_shas``
250
+ cache starts empty.
251
+ """
252
+ refs_dir = repo.git_dir / "refs" / "heads"
253
+ if refs_dir.is_dir():
254
+ for ref_file in _iter_ref_files(refs_dir):
255
+ branch = _branch_name_from_ref(refs_dir, ref_file)
256
+ sha = _read_ref(ref_file)
257
+ if sha:
258
+ repo.branch_shas[branch] = sha
259
+
260
+ head_file = repo.git_dir / "HEAD"
261
+ if head_file.exists():
262
+ repo.head_branch = _read_head_branch(head_file)
263
+
264
+ # ------------------------------------------------------------------
265
+ # Watchdog callbacks (thread → asyncio bridge)
266
+ # ------------------------------------------------------------------
267
+
268
+ def _on_ref_change(self, repo: _WatchedRepo, event_path: str) -> None:
269
+ """Called on the watchdog thread when a ref file changes."""
270
+ loop = self._loop
271
+ if loop is None or loop.is_closed():
272
+ return
273
+ loop.call_soon_threadsafe(
274
+ lambda: asyncio.create_task(self._handle_ref_change_async(repo, event_path))
275
+ )
276
+
277
+ async def _handle_ref_change_async(self, repo: _WatchedRepo, event_path: str) -> None:
278
+ """Runs on the asyncio loop - inspect the ref and publish if changed."""
279
+ path = Path(event_path)
280
+ refs_dir = repo.git_dir / "refs" / "heads"
281
+ head_file = repo.git_dir / "HEAD"
282
+
283
+ try:
284
+ if path == head_file or path.name == "HEAD":
285
+ new_branch = _read_head_branch(head_file) if head_file.exists() else None
286
+ if new_branch and new_branch != repo.head_branch:
287
+ # Pentest 2026-04-26 (MEDIUM): branch names traverse the
288
+ # bus to the DO ingest path. Reject anything that doesn't
289
+ # match the conservative shape gate before publishing -
290
+ # don't update head_branch on reject so the next valid
291
+ # change still fires.
292
+ if not _is_safe_branch_name(new_branch):
293
+ logger.warning(
294
+ "git_watcher rejecting unsafe HEAD branch name=%r repo=%s",
295
+ new_branch,
296
+ repo.path,
297
+ )
298
+ return
299
+ previous = repo.head_branch
300
+ repo.head_branch = new_branch
301
+ await self._publish(
302
+ "git_branch_switch",
303
+ {
304
+ "repo": str(repo.path),
305
+ "branch": new_branch,
306
+ "previous": previous,
307
+ },
308
+ )
309
+ return
310
+
311
+ # Ref file under .git/refs/heads/
312
+ try:
313
+ path.relative_to(refs_dir)
314
+ except ValueError:
315
+ return
316
+
317
+ if not path.exists() or not path.is_file():
318
+ return
319
+ branch = _branch_name_from_ref(refs_dir, path)
320
+ # Pentest 2026-04-26 (MEDIUM): sanitise the branch name before it
321
+ # reaches the bus. _branch_name_from_ref derives from the on-disk
322
+ # ref filename which a same-UID attacker can plant arbitrarily.
323
+ if not _is_safe_branch_name(branch):
324
+ logger.warning(
325
+ "git_watcher rejecting unsafe ref branch name=%r repo=%s",
326
+ branch,
327
+ repo.path,
328
+ )
329
+ return
330
+ sha = _read_ref(path)
331
+ if not sha:
332
+ return
333
+ previous = repo.branch_shas.get(branch)
334
+ if previous == sha:
335
+ return # debounce
336
+ repo.branch_shas[branch] = sha
337
+ await self._publish(
338
+ "git_commit",
339
+ {
340
+ "repo": str(repo.path),
341
+ "branch": branch,
342
+ "sha": sha,
343
+ "previous": previous,
344
+ },
345
+ )
346
+ except Exception as exc: # pragma: no cover
347
+ logger.warning("git_watcher ref change handling failed: %s", exc)
348
+
349
+ async def _publish(self, kind: str, payload: dict[str, Any]) -> None:
350
+ logger.info(
351
+ "git_watcher publishing kind=%s repo=%s branch=%s",
352
+ kind,
353
+ payload.get("repo"),
354
+ payload.get("branch"),
355
+ )
356
+ await self._bus.publish(
357
+ EGRESS_TOPIC,
358
+ {"kind": kind, "payload": payload, "source": "git_watcher"},
359
+ )
360
+
361
+ # ------------------------------------------------------------------
362
+ # Test introspection
363
+ # ------------------------------------------------------------------
364
+
365
+ @property
366
+ def watched_repos(self) -> list[_WatchedRepo]:
367
+ return list(self._repos)
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # watchdog event handler - sits between the observer thread and the loop
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ class _GitRefHandler:
376
+ """Tiny wrapper - watchdog's FileSystemEventHandler is imported lazily
377
+ inside :meth:`GitWatcher.run` so that installs without watchdog can still
378
+ import ``git_watcher``. This class shims the interface without subclassing
379
+ so type checkers don't demand the import at module-load time.
380
+ """
381
+
382
+ def __init__(self, watcher: GitWatcher, repo: _WatchedRepo) -> None:
383
+ self._watcher = watcher
384
+ self._repo = repo
385
+ # Cache the thread ID we were constructed on for debug logging.
386
+ self._construct_thread = threading.get_ident()
387
+
388
+ # watchdog calls these via duck typing; no base class required.
389
+ def dispatch(self, event: Any) -> None:
390
+ if getattr(event, "is_directory", False):
391
+ return
392
+ # Drop pure-read events. Modern watchdog versions (>=2.x with
393
+ # IN_OPEN/IN_CLOSE_NOWRITE in the default mask) emit a
394
+ # FileOpenedEvent + FileClosedNoWriteEvent for every open-and-read
395
+ # of a watched file. ``.git/HEAD`` and the active branch ref are
396
+ # opened thousands of times per second by ``git status`` callers
397
+ # (IDE git plugins, statusline scripts, parallel CC sessions), and
398
+ # without this filter every read scheduled an ``asyncio.create_task``
399
+ # via ``_on_ref_change`` - flooding the loop with no-op handles
400
+ # that grew RSS by ~10MB/s and tripped OOM in <5min on a busy repo.
401
+ kind = type(event).__name__
402
+ if kind in ("FileOpenedEvent", "FileClosedNoWriteEvent"):
403
+ return
404
+ src = getattr(event, "src_path", None)
405
+ dest = getattr(event, "dest_path", None)
406
+ # Fire on whichever path exists after the event (move target for
407
+ # renames, src for create/modify).
408
+ target = dest or src
409
+ if not isinstance(target, str):
410
+ return
411
+ self._watcher._on_ref_change(self._repo, target)
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # Ref file helpers
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ def _iter_ref_files(refs_dir: Path):
420
+ """Yield every ref file under ``refs_dir`` recursively."""
421
+ for root, _dirs, files in os.walk(refs_dir):
422
+ for fname in files:
423
+ yield Path(root) / fname
424
+
425
+
426
+ def _branch_name_from_ref(refs_dir: Path, ref_file: Path) -> str:
427
+ """Return the branch name given a path under ``.git/refs/heads/``."""
428
+ try:
429
+ return str(ref_file.relative_to(refs_dir))
430
+ except ValueError:
431
+ return ref_file.name
432
+
433
+
434
+ def _read_ref(ref_file: Path) -> str | None:
435
+ """Read a ref file and return the SHA, or None on read failure."""
436
+ try:
437
+ return ref_file.read_text(encoding="utf-8").strip()
438
+ except (OSError, UnicodeDecodeError):
439
+ return None
440
+
441
+
442
+ def _read_head_branch(head_file: Path) -> str | None:
443
+ """Return the branch HEAD points at, or None if detached / unreadable.
444
+
445
+ ``.git/HEAD`` is either ``ref: refs/heads/<branch>`` for an attached head
446
+ or a bare SHA for a detached head.
447
+ """
448
+ try:
449
+ content = head_file.read_text(encoding="utf-8").strip()
450
+ except (OSError, UnicodeDecodeError):
451
+ return None
452
+ if content.startswith("ref:"):
453
+ _, _, ref = content.partition("ref:")
454
+ ref = ref.strip()
455
+ if ref.startswith("refs/heads/"):
456
+ return ref[len("refs/heads/") :]
457
+ return None
@@ -0,0 +1,29 @@
1
+ """Household substrate adapters (Phase 2 physical-substrate widening).
2
+
3
+ Scaffolded under the Wave-A Tapo pilot for ~blake's homeserver gear:
4
+
5
+ * :mod:`workshop_tools` (Eco-12) — per-plug Wh fingerprint via MQTT.
6
+ * :mod:`compost` (Eco-9) — pile-temp curve via MQTT, plate-tag filtered.
7
+ * :mod:`tapo_ecosystem` (Eco-14) — hub-level multi-parameter composition.
8
+ * :mod:`self_hoster` (Eco-15) — own-host systemd + filesystem polling.
9
+
10
+ All four conform to D-PROV-1 (RATIFIED 2026-05-13) and the IaI 5-clause
11
+ overlay; each ships a hard-coded Clause-4 banlist refused at trait-emit
12
+ time. Bands only — raw Wh / temp / event counts never cross the daemon
13
+ boundary upward.
14
+
15
+ NOT auto-wired into :mod:`alter_runtime.daemon` startup; activation is a
16
+ follow-up wave once the stub specs in
17
+ ``.repos/internal/02-Technical-Strategy/phase2-wave2-stub-specs-pack-2026-05-27.md``
18
+ land their implementation pass.
19
+ """
20
+
21
+ from alter_runtime.adapters.household._base import (
22
+ EventBusSubscriberBase,
23
+ PassiveLanPollerBase,
24
+ )
25
+
26
+ __all__ = [
27
+ "EventBusSubscriberBase",
28
+ "PassiveLanPollerBase",
29
+ ]
@@ -0,0 +1,138 @@
1
+ """Shared base classes for the household adapter family.
2
+
3
+ Two adapter shapes per the Phase-2 cross-pattern catalogue
4
+ (``.repos/internal/02-Technical-Strategy/phase2-substrate-cross-pattern-catalogue-2026-05-19.md``):
5
+
6
+ * :class:`EventBusSubscriberBase` — subscribes to the in-process
7
+ :class:`~alter_runtime.subscribers.bus.EventBus` for events the
8
+ household-bridge subscriber (or a future HA/MQTT shim) publishes from
9
+ the maker's LAN. Used by ``workshop_tools``, ``compost``, and
10
+ ``tapo_ecosystem``.
11
+ * :class:`PassiveLanPollerBase` — polls a local resource (own host,
12
+ paired LAN device) on a fixed cadence. Used by ``self_hoster``
13
+ (degenerate-LAN: the daemon polls its own host).
14
+
15
+ Both extend :class:`~alter_runtime.daemon.Component` so the supervisor
16
+ can run them as long-lived tasks once wired in. They deliberately stop
17
+ short of imposing topic taxonomy, payload shape, or storage choice —
18
+ those are per-adapter concerns.
19
+
20
+ D-PROV-1 (RATIFIED 2026-05-13) governs all consumers; IaI 5-clause map
21
+ sits in the per-adapter ``traits.py`` modules so the banlist is
22
+ co-located with the trait emit site that enforces it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ from abc import abstractmethod
30
+ from typing import Any
31
+
32
+ from alter_runtime.config import DaemonConfig
33
+ from alter_runtime.daemon import Component
34
+ from alter_runtime.subscribers.bus import EventBus
35
+
36
+ __all__ = [
37
+ "EventBusSubscriberBase",
38
+ "PassiveLanPollerBase",
39
+ ]
40
+
41
+
42
+ class EventBusSubscriberBase(Component):
43
+ """Base for adapters that subscribe to in-process bus events.
44
+
45
+ Subclasses set :attr:`subscribe_topics` (one or more topic strings)
46
+ and implement :meth:`handle_event`. The base wires subscribe /
47
+ unsubscribe symmetry around the supervisor lifecycle and shields the
48
+ supervisor from subscriber exceptions (the bus already does this,
49
+ but adapter-side logging is more useful for debugging).
50
+ """
51
+
52
+ name: str = "household_event_bus_subscriber"
53
+ #: Topics this adapter subscribes to. Set on the subclass.
54
+ subscribe_topics: tuple[str, ...] = ()
55
+
56
+ def __init__(self, config: DaemonConfig, bus: EventBus) -> None:
57
+ self._config = config
58
+ self._bus = bus
59
+ self._stop_event = asyncio.Event()
60
+ self._logger = logging.getLogger(f"alter_runtime.adapters.household.{self.name}")
61
+
62
+ async def run(self) -> None:
63
+ if not self.subscribe_topics:
64
+ self._logger.info("%s no subscribe_topics declared — idle", self.name)
65
+ await self._stop_event.wait()
66
+ return
67
+
68
+ for topic in self.subscribe_topics:
69
+ self._bus.subscribe(topic, self._dispatch)
70
+ self._logger.info("%s subscribed topics=%s", self.name, list(self.subscribe_topics))
71
+
72
+ try:
73
+ await self._stop_event.wait()
74
+ finally:
75
+ for topic in self.subscribe_topics:
76
+ self._bus.unsubscribe(topic, self._dispatch)
77
+ self._logger.info("%s stopped", self.name)
78
+
79
+ async def stop(self) -> None:
80
+ self._stop_event.set()
81
+
82
+ async def _dispatch(self, payload: Any) -> None:
83
+ try:
84
+ await self.handle_event(payload)
85
+ except Exception as exc: # pragma: no cover — defensive
86
+ self._logger.warning("%s handle_event raised: %s", self.name, exc)
87
+
88
+ @abstractmethod
89
+ async def handle_event(self, payload: Any) -> None:
90
+ """Process one bus event. Subclass responsibility."""
91
+
92
+
93
+ class PassiveLanPollerBase(Component):
94
+ """Base for adapters that poll a local resource on a fixed cadence.
95
+
96
+ Subclasses set :attr:`poll_interval_seconds` and implement
97
+ :meth:`poll_once`. The base supervises the poll loop, sleeps
98
+ between iterations, and exits cleanly on shutdown.
99
+
100
+ ``self_hoster`` (Eco-15) is the canonical degenerate-LAN consumer
101
+ — the daemon polls its own host's systemd D-Bus + filesystem; no
102
+ network calls cross the loopback interface.
103
+ """
104
+
105
+ name: str = "household_passive_lan_poller"
106
+ #: Default 6 hours per the self-hoster stub spec.
107
+ poll_interval_seconds: float = 6 * 60 * 60
108
+
109
+ def __init__(self, config: DaemonConfig) -> None:
110
+ self._config = config
111
+ self._stop_event = asyncio.Event()
112
+ self._logger = logging.getLogger(f"alter_runtime.adapters.household.{self.name}")
113
+
114
+ async def run(self) -> None:
115
+ self._logger.info(
116
+ "%s starting poll loop interval=%.0fs", self.name, self.poll_interval_seconds
117
+ )
118
+ try:
119
+ while not self._stop_event.is_set():
120
+ try:
121
+ await self.poll_once()
122
+ except Exception as exc: # pragma: no cover — defensive
123
+ self._logger.warning("%s poll_once raised: %s", self.name, exc)
124
+ try:
125
+ await asyncio.wait_for(
126
+ self._stop_event.wait(), timeout=self.poll_interval_seconds
127
+ )
128
+ except asyncio.TimeoutError:
129
+ pass
130
+ finally:
131
+ self._logger.info("%s stopped", self.name)
132
+
133
+ async def stop(self) -> None:
134
+ self._stop_event.set()
135
+
136
+ @abstractmethod
137
+ async def poll_once(self) -> None:
138
+ """Run one poll cycle. Subclass responsibility."""
@@ -0,0 +1,17 @@
1
+ """Compost substrate adapter (Eco-9).
2
+
3
+ Subscribes to MQTT topic glob ``tapo/temp_humid/+/state``, filters to
4
+ devices whose member-attested plate tag is ``"compost"``, and computes
5
+ a 30-day temperature-curve patience trait (band). Persists to
6
+ ``~/.local/share/alter-runtime/compost.db`` (mode 600).
7
+
8
+ Spec: ``.repos/internal/02-Technical-Strategy/compost-substrate-adapter-2026-05-19.md``
9
+ """
10
+
11
+ from alter_runtime.adapters.household.compost.adapter import CompostAdapter
12
+ from alter_runtime.adapters.household.compost.traits import (
13
+ CLAUSE_4_BANLIST,
14
+ CompostTraits,
15
+ )
16
+
17
+ __all__ = ["CLAUSE_4_BANLIST", "CompostAdapter", "CompostTraits"]