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,95 @@
1
+ """Workshop-tools trait band emitters (Eco-12).
2
+
3
+ Trait floor (spec §3):
4
+
5
+ * ``workshop_steward_active`` (boolean band)
6
+ * ``focus_block_depth`` (banded: ``{shallow, moderate, deep, deeply-focused}``)
7
+ * ``craft_breadth_band`` (banded: ``{single-tool, narrow-toolkit, broad-toolkit, wide-bench}``)
8
+
9
+ Clause-4 banlist enforced at emit-time (spec §4):
10
+
11
+ * ``workshop-affect``
12
+ * ``craft-burnout``
13
+ * ``focus-fatigue``
14
+ * ``production-pressure``
15
+ * ``flow-state-inference``
16
+ * ``classroom-workshop-suitability``
17
+
18
+ Any caller asking the trait emitter for one of these kinds is refused
19
+ with :class:`BannedTraitKindError` before any persisted band is touched.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import Literal
26
+
27
+ from pydantic import BaseModel, Field
28
+
29
+ __all__ = [
30
+ "CLAUSE_4_BANLIST",
31
+ "BannedTraitKindError",
32
+ "CraftBreadthBand",
33
+ "FocusBlockBand",
34
+ "WorkshopToolsTraits",
35
+ "WorkshopToolsTraitEmission",
36
+ ]
37
+
38
+ logger = logging.getLogger("alter_runtime.adapters.household.workshop_tools.traits")
39
+
40
+ #: Hard-coded refusal set per spec §4 + stub spec. Frozenset so it
41
+ #: cannot be mutated at runtime.
42
+ CLAUSE_4_BANLIST: frozenset[str] = frozenset(
43
+ {
44
+ "workshop-affect",
45
+ "craft-burnout",
46
+ "focus-fatigue",
47
+ "production-pressure",
48
+ "flow-state-inference",
49
+ "classroom-workshop-suitability",
50
+ }
51
+ )
52
+
53
+ FocusBlockBand = Literal["shallow", "moderate", "deep", "deeply-focused"]
54
+ CraftBreadthBand = Literal["single-tool", "narrow-toolkit", "broad-toolkit", "wide-bench"]
55
+
56
+
57
+ class BannedTraitKindError(RuntimeError):
58
+ """Raised when a caller requests a Clause-4-banned trait kind."""
59
+
60
+
61
+ class WorkshopToolsTraitEmission(BaseModel):
62
+ """One trait-band emission ready for the egress producer."""
63
+
64
+ kind: str = Field(description="Trait kind, e.g. 'focus_block_depth'")
65
+ band: str = Field(description="The banded label (never numeric)")
66
+ provenance: str = Field(default="passive_local_sensor")
67
+ stream: str = Field(default="workshop_tool")
68
+
69
+
70
+ class WorkshopToolsTraits:
71
+ """Trait emitter with Clause-4 banlist enforcement."""
72
+
73
+ def emit(self, kind: str, band: str) -> WorkshopToolsTraitEmission:
74
+ """Emit a banded trait, refusing Clause-4-banned kinds outright."""
75
+ self._refuse_if_banned(kind)
76
+ # TODO(clause-3-identity-income): return/x402 hook attaches here per
77
+ # D-PROV-1 + clause-3 ratification. Each emission upward is the
78
+ # point at which Identity-Income return-flow is metered. Hook
79
+ # implementation lives outside this scaffold pass.
80
+ return WorkshopToolsTraitEmission(kind=kind, band=band)
81
+
82
+ def emit_focus_block_depth(self, band: FocusBlockBand) -> WorkshopToolsTraitEmission:
83
+ return self.emit("focus_block_depth", band)
84
+
85
+ def emit_craft_breadth_band(self, band: CraftBreadthBand) -> WorkshopToolsTraitEmission:
86
+ return self.emit("craft_breadth_band", band)
87
+
88
+ def emit_workshop_steward_active(self, active: bool) -> WorkshopToolsTraitEmission:
89
+ return self.emit("workshop_steward_active", "active" if active else "inactive")
90
+
91
+ @staticmethod
92
+ def _refuse_if_banned(kind: str) -> None:
93
+ if kind in CLAUSE_4_BANLIST:
94
+ logger.warning("workshop_tools refusing Clause-4-banned trait kind=%s", kind)
95
+ raise BannedTraitKindError(f"trait kind {kind!r} is Clause-4 banned for workshop_tools")
@@ -0,0 +1,378 @@
1
+ """WorktreeWatcher - ambient working-tree fs-watch for the Weave coordination plane.
2
+
3
+ D-WEAVE-VC-2 §8 item 1 (b): plain-editor save tap. Observes one or more repo
4
+ working trees and publishes ``local.signal`` events of kind ``"worktree_edit"``
5
+ on every file save detected by watchdog. This covers non-CC editors (vim, vscode,
6
+ etc.) as a coarse-grained second producer for the weave-intent stream.
7
+
8
+ Signals
9
+ -------
10
+
11
+ * ``kind = "worktree_edit"`` - a working-tree file was saved. Payload::
12
+
13
+ {
14
+ "repo": "/abs/path/to/repo",
15
+ "file_path": "/abs/path/to/repo/src/foo.py",
16
+ "rel_path": "src/foo.py",
17
+ "ts": "2026-05-21T12:34:56.789012+00:00",
18
+ }
19
+
20
+ Published on the ``local.signal`` topic. ``WeaveIntentWriter`` subscribes and
21
+ projects the event into ``weave-intent.jsonl`` as a coarse ``worktree_edit``
22
+ record (D-WEAVE-VC-2 §3 degradation path — "dangling edge").
23
+
24
+ Design notes
25
+ ------------
26
+
27
+ * **Whole-tree watcher is MORE flood-exposed than GitWatcher.** The same
28
+ read-event filter from ``git_watcher.py`` is mandatory here and is applied
29
+ in every dispatch path — dropping ``FileOpenedEvent`` + ``FileClosedNoWriteEvent``
30
+ before they ever reach the asyncio loop.
31
+ * **Debounce per path.** Within a 200 ms window, multiple watchdog events for
32
+ the same file path emit only one bus event. Each repo tracks a ``dict[str, float]``
33
+ of path → last-emit-time.
34
+ * **gitignore + symlink guards.** Reuses the same patterns from GitWatcher:
35
+ refuse to watch a repo where ``.git`` is a symlink; skip symlinked files;
36
+ honour ``.gitignore`` by filtering against a cached ignore spec on each event.
37
+ * **Thread boundary.** watchdog callbacks run on the observer's own thread.
38
+ Marshalled onto the asyncio loop via ``loop.call_soon_threadsafe`` before
39
+ publishing — the bus is not thread-safe.
40
+ * **Autodetect CWD.** When ``repo_paths`` is empty and the CWD is a git repo,
41
+ the adapter watches the CWD. Same convention as GitWatcher.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import contextlib
48
+ import logging
49
+ import threading
50
+ import time
51
+ from dataclasses import dataclass, field
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ from alter_runtime.config import DaemonConfig
57
+ from alter_runtime.daemon import Component
58
+ from alter_runtime.subscribers.bus import EventBus
59
+
60
+ __all__ = ["WorktreeWatcher"]
61
+
62
+ logger = logging.getLogger("alter_runtime.adapters.worktree_watcher")
63
+
64
+ EGRESS_TOPIC: str = "local.signal"
65
+
66
+ #: Debounce window in seconds — events within this window for the same path
67
+ #: are collapsed to one emission.
68
+ DEBOUNCE_SECONDS: float = 0.2
69
+
70
+ #: Maximum number of paths tracked in the per-repo debounce dict. When
71
+ #: exceeded, the oldest half is evicted to prevent unbounded growth on
72
+ #: very active repos (monorepo with hundreds of simultaneous writes).
73
+ MAX_DEBOUNCE_PATHS: int = 2048
74
+
75
+
76
+ @dataclass
77
+ class _WatchedRepo:
78
+ """Bookkeeping for one watched repository."""
79
+
80
+ path: Path
81
+ git_dir: Path
82
+ observer: Any | None = None
83
+ #: path string → last emit timestamp (monotonic)
84
+ _debounce: dict[str, float] = field(default_factory=dict)
85
+ #: Cached gitignore spec (pathspec.PathSpec | None). Populated lazily.
86
+ _ignore_spec: Any | None = None
87
+ _ignore_loaded: bool = False
88
+
89
+
90
+ class WorktreeWatcher(Component):
91
+ """Watches working trees and publishes worktree_edit signals.
92
+
93
+ Parameters
94
+ ----------
95
+ config:
96
+ Loaded :class:`DaemonConfig`.
97
+ bus:
98
+ Shared :class:`EventBus` — signals are published on ``local.signal``.
99
+ repo_paths:
100
+ Explicit list of repository roots to watch. If empty, autodetects
101
+ from CWD (same convention as GitWatcher).
102
+ debounce_seconds:
103
+ Override the per-path debounce window (default 200 ms).
104
+ """
105
+
106
+ name = "worktree_watcher"
107
+
108
+ def __init__(
109
+ self,
110
+ config: DaemonConfig,
111
+ bus: EventBus,
112
+ repo_paths: list[Path] | None = None,
113
+ debounce_seconds: float = DEBOUNCE_SECONDS,
114
+ ) -> None:
115
+ self._config = config
116
+ self._bus = bus
117
+ self._explicit_paths = repo_paths
118
+ self._debounce_seconds = debounce_seconds
119
+ self._stop_event = asyncio.Event()
120
+ self._loop: asyncio.AbstractEventLoop | None = None
121
+ self._repos: list[_WatchedRepo] = []
122
+
123
+ # ------------------------------------------------------------------
124
+ # Component lifecycle
125
+ # ------------------------------------------------------------------
126
+
127
+ async def run(self) -> None:
128
+ self._loop = asyncio.get_running_loop()
129
+ repos = self._resolve_repos()
130
+ if not repos:
131
+ logger.info("worktree_watcher: no repositories to watch — idle")
132
+ await self._stop_event.wait()
133
+ return
134
+
135
+ try:
136
+ from watchdog.observers import Observer # noqa: F401
137
+ except ImportError:
138
+ logger.warning("watchdog not installed — worktree_watcher disabled")
139
+ await self._stop_event.wait()
140
+ return
141
+
142
+ from watchdog.observers import Observer
143
+
144
+ for repo in repos:
145
+ observer = Observer()
146
+ handler = _WorktreeHandler(self, repo)
147
+ # Schedule on the repo root (recursive). We watch the working
148
+ # tree, NOT the .git dir — gitignore filter handles noise from
149
+ # .git/ appearing if the observer root is above it.
150
+ observer.schedule(handler, str(repo.path), recursive=True)
151
+ observer.daemon = True
152
+ observer.start()
153
+ repo.observer = observer
154
+ self._repos.append(repo)
155
+ logger.info(
156
+ "worktree_watcher observing repo=%s",
157
+ repo.path,
158
+ )
159
+
160
+ try:
161
+ await self._stop_event.wait()
162
+ finally:
163
+ for repo in self._repos:
164
+ if repo.observer is not None:
165
+ with contextlib.suppress(Exception):
166
+ repo.observer.stop()
167
+ repo.observer.join(timeout=2.0)
168
+ logger.info("worktree_watcher stopped")
169
+
170
+ async def stop(self) -> None:
171
+ self._stop_event.set()
172
+
173
+ # ------------------------------------------------------------------
174
+ # Repo discovery
175
+ # ------------------------------------------------------------------
176
+
177
+ def _resolve_repos(self) -> list[_WatchedRepo]:
178
+ paths: list[Path] = []
179
+ if self._explicit_paths:
180
+ paths = [Path(p).expanduser().resolve() for p in self._explicit_paths]
181
+ else:
182
+ cwd = Path.cwd().resolve()
183
+ git_dir = cwd / ".git"
184
+ if git_dir.is_dir() or git_dir.is_file():
185
+ paths = [cwd]
186
+
187
+ repos: list[_WatchedRepo] = []
188
+ for path in paths:
189
+ git_dir = path / ".git"
190
+ # Pentest mirror from git_watcher.py: refuse symlinked .git.
191
+ if git_dir.is_symlink():
192
+ logger.warning(
193
+ "worktree_watcher: refusing symlinked .git path=%s -> %s",
194
+ git_dir,
195
+ git_dir.resolve(strict=False),
196
+ )
197
+ continue
198
+ if not git_dir.exists():
199
+ logger.warning(
200
+ "worktree_watcher: skipping non-repo path=%s",
201
+ path,
202
+ )
203
+ continue
204
+ repos.append(_WatchedRepo(path=path, git_dir=git_dir))
205
+ return repos
206
+
207
+ # ------------------------------------------------------------------
208
+ # Watchdog callbacks (thread → asyncio bridge)
209
+ # ------------------------------------------------------------------
210
+
211
+ def _on_file_modified(self, repo: _WatchedRepo, abs_path: str) -> None:
212
+ """Called on the watchdog observer thread when a file write is detected."""
213
+ loop = self._loop
214
+ if loop is None or loop.is_closed():
215
+ return
216
+ loop.call_soon_threadsafe(
217
+ lambda: asyncio.create_task(self._handle_file_modified_async(repo, abs_path))
218
+ )
219
+
220
+ async def _handle_file_modified_async(self, repo: _WatchedRepo, abs_path: str) -> None:
221
+ """Runs on the asyncio loop — apply guards, debounce, and publish."""
222
+ try:
223
+ p = Path(abs_path)
224
+
225
+ # Skip symlinks (pentest pattern from git_watcher)
226
+ if p.is_symlink():
227
+ return
228
+
229
+ # Skip .git/ internals — we only care about working-tree files
230
+ try:
231
+ p.relative_to(repo.git_dir)
232
+ return # inside .git — skip
233
+ except ValueError:
234
+ pass
235
+
236
+ # Skip gitignored paths
237
+ if self._is_ignored(repo, p):
238
+ return
239
+
240
+ # Debounce: collapse rapid writes to the same path
241
+ now = time.monotonic()
242
+ last = repo._debounce.get(abs_path, 0.0)
243
+ if now - last < self._debounce_seconds:
244
+ return
245
+
246
+ # Evict oldest if debounce dict is growing too large
247
+ if len(repo._debounce) >= MAX_DEBOUNCE_PATHS:
248
+ oldest_half = sorted(repo._debounce, key=lambda k: repo._debounce[k])[
249
+ : MAX_DEBOUNCE_PATHS // 2
250
+ ]
251
+ for k in oldest_half:
252
+ del repo._debounce[k]
253
+
254
+ repo._debounce[abs_path] = now
255
+
256
+ try:
257
+ rel_path = str(p.relative_to(repo.path))
258
+ except ValueError:
259
+ rel_path = abs_path
260
+
261
+ ts = datetime.now(timezone.utc).isoformat()
262
+ await self._publish(
263
+ repo,
264
+ {
265
+ "repo": str(repo.path),
266
+ "file_path": abs_path,
267
+ "rel_path": rel_path,
268
+ "ts": ts,
269
+ },
270
+ )
271
+
272
+ except Exception as exc: # pragma: no cover
273
+ logger.warning("worktree_watcher: file modified handling failed: %s", exc)
274
+
275
+ async def _publish(self, repo: _WatchedRepo, payload: dict[str, Any]) -> None:
276
+ logger.debug(
277
+ "worktree_watcher publishing kind=worktree_edit repo=%s file=%s",
278
+ payload.get("repo"),
279
+ payload.get("rel_path"),
280
+ )
281
+ await self._bus.publish(
282
+ EGRESS_TOPIC,
283
+ {"kind": "worktree_edit", "payload": payload, "source": "worktree_watcher"},
284
+ )
285
+
286
+ # ------------------------------------------------------------------
287
+ # gitignore filtering
288
+ # ------------------------------------------------------------------
289
+
290
+ def _is_ignored(self, repo: _WatchedRepo, path: Path) -> bool:
291
+ """Return True if ``path`` is covered by the repo's .gitignore."""
292
+ if not repo._ignore_loaded:
293
+ repo._ignore_spec = _load_ignore_spec(repo.path)
294
+ repo._ignore_loaded = True
295
+
296
+ ignore_spec = repo._ignore_spec
297
+ if ignore_spec is None:
298
+ return False
299
+
300
+ try:
301
+ rel = path.relative_to(repo.path)
302
+ return bool(ignore_spec.match_file(str(rel)))
303
+ except (ValueError, Exception):
304
+ return False
305
+
306
+ # ------------------------------------------------------------------
307
+ # Test introspection
308
+ # ------------------------------------------------------------------
309
+
310
+ @property
311
+ def watched_repos(self) -> list[_WatchedRepo]:
312
+ return list(self._repos)
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # watchdog event handler
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ class _WorktreeHandler:
321
+ """Shims watchdog's FileSystemEventHandler without subclassing it.
322
+
323
+ Only write-class events (Modified, Created, Moved-dest) reach the watcher.
324
+ Read events (FileOpenedEvent, FileClosedNoWriteEvent) are dropped at this
325
+ level — the same filter documented in git_watcher.py that prevented the
326
+ ~10MB/s RSS flood from open-and-read bursts.
327
+ """
328
+
329
+ def __init__(self, watcher: WorktreeWatcher, repo: _WatchedRepo) -> None:
330
+ self._watcher = watcher
331
+ self._repo = repo
332
+ self._construct_thread = threading.get_ident()
333
+
334
+ def dispatch(self, event: Any) -> None:
335
+ if getattr(event, "is_directory", False):
336
+ return
337
+
338
+ # Read-event filter — critical for whole-tree watchers.
339
+ kind = type(event).__name__
340
+ if kind in ("FileOpenedEvent", "FileClosedNoWriteEvent"):
341
+ return
342
+
343
+ # For moves, use the destination path (the new file that now exists)
344
+ src = getattr(event, "src_path", None)
345
+ dest = getattr(event, "dest_path", None)
346
+ target = dest if dest and kind in ("FileMovedEvent",) else src
347
+ if not isinstance(target, str):
348
+ return
349
+
350
+ self._watcher._on_file_modified(self._repo, target)
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # gitignore helpers
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ def _load_ignore_spec(repo_root: Path) -> Any | None:
359
+ """Load a pathspec.PathSpec from the repo's .gitignore.
360
+
361
+ Returns ``None`` if pathspec is not installed or the .gitignore does not
362
+ exist — callers treat None as "nothing is ignored".
363
+ """
364
+ try:
365
+ import pathspec # type: ignore[import-untyped]
366
+ except ImportError:
367
+ return None
368
+
369
+ gitignore = repo_root / ".gitignore"
370
+ if not gitignore.exists():
371
+ return None
372
+
373
+ try:
374
+ lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
375
+ return pathspec.PathSpec.from_lines("gitwildmatch", lines)
376
+ except Exception as exc:
377
+ logger.debug("worktree_watcher: failed to load .gitignore at %s: %s", gitignore, exc)
378
+ return None
@@ -0,0 +1,48 @@
1
+ """Atlas - substrate-recognition primitive.
2
+
3
+ Post-Encounter depth surface that reads ambient device substrate (git, shell,
4
+ vault, windows, downloads, calendar, notes, recents, aesthetic) and emits
5
+ content-free 128-dim signature vectors. Content stays local forever; only
6
+ derivative signature hashes cross the consent boundary, under per-stream
7
+ Art-6(1)(a) consent.
8
+
9
+ Spec: .repos/internal/02-Technical-Strategy/substrate-recognition-exploration-2026-04-17.md
10
+ Plan: .repos/internal/02-Technical-Strategy/atlas-weekend-build-plan-2026-04-18.md
11
+ DR candidate: D-VLT1 (Drew-gated).
12
+
13
+ Honesty clause (spec §2): signatures are DERIVATIVE, not pseudonymous. A 128-dim
14
+ vector over lifestyle substrates is richer than a browser fingerprint and is
15
+ re-identifiable via membership inference at scale. The protection ladder for a
16
+ server-bound signature is therefore legal + operational, not architectural.
17
+ Any copy, schema comment, or consent UI string that claims "content-free" for
18
+ server-crossing signatures is a regression.
19
+ """
20
+
21
+ from alter_runtime.atlas.base import DryRunManifest, PathEntry, SubstrateAdapter
22
+ from alter_runtime.atlas.ledger import Ledger, LedgerEntry
23
+ from alter_runtime.atlas.observations import (
24
+ Observation,
25
+ lint_observation_line,
26
+ render_observations,
27
+ )
28
+ from alter_runtime.atlas.schema import (
29
+ ProvenanceClass,
30
+ Signature,
31
+ SignatureCoefficient,
32
+ SubstrateStream,
33
+ )
34
+
35
+ __all__ = [
36
+ "DryRunManifest",
37
+ "Ledger",
38
+ "LedgerEntry",
39
+ "Observation",
40
+ "PathEntry",
41
+ "ProvenanceClass",
42
+ "Signature",
43
+ "SignatureCoefficient",
44
+ "SubstrateAdapter",
45
+ "SubstrateStream",
46
+ "lint_observation_line",
47
+ "render_observations",
48
+ ]
@@ -0,0 +1,102 @@
1
+ """SubstrateAdapter - base class for all Atlas adapters.
2
+
3
+ Each adapter subclass reads one stream (git, shell, vault, ...) and produces a
4
+ Signature. The contract is:
5
+
6
+ * ``dry_run(config)`` - enumerate what WOULD be read without reading. Returns
7
+ a DryRunManifest the user reviews before granting the read.
8
+ * ``extract(config)`` - perform the read, produce a Signature, discard content.
9
+ * ``supported_coefficients`` - class-level tuple of which slots this adapter
10
+ populates (used for Mirror citation templates + consent UI).
11
+
12
+ Adapters NEVER write to the substrate, NEVER retain content, NEVER cross the
13
+ network directly. Egress is the parent daemon's responsibility under per-stream
14
+ consent.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from alter_runtime.atlas.schema import (
25
+ ProvenanceClass,
26
+ Signature,
27
+ SubstrateStream,
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PathEntry:
33
+ """One path the adapter would read, with byte budget."""
34
+
35
+ path: Path
36
+ bytes_to_read: int
37
+ purpose: str # one-line description shown in consent UI
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DryRunManifest:
42
+ """What an adapter WOULD read - shown to the user before any consent grant.
43
+
44
+ The user approves paths, not concepts. Consent UI renders this manifest
45
+ verbatim. Bytes_total is advisory; adapters are expected to be within 10%.
46
+ """
47
+
48
+ stream: SubstrateStream
49
+ entries: tuple[PathEntry, ...]
50
+ bytes_total: int
51
+ extraction_version: int
52
+ coefficients_populated: tuple[str, ...] # names from schema
53
+ summary: str # one-line pitch, e.g. "your git commit rhythm"
54
+
55
+ @classmethod
56
+ def empty(cls, stream: SubstrateStream, extraction_version: int) -> "DryRunManifest":
57
+ """Manifest for an adapter that found nothing to read on this device."""
58
+ return cls(
59
+ stream=stream,
60
+ entries=(),
61
+ bytes_total=0,
62
+ extraction_version=extraction_version,
63
+ coefficients_populated=(),
64
+ summary=f"no {stream.value} substrate found on this device",
65
+ )
66
+
67
+
68
+ class SubstrateAdapter(ABC):
69
+ """Base for Atlas substrate adapters.
70
+
71
+ Subclass contract:
72
+ * class-level ``stream`` = SubstrateStream enum member
73
+ * class-level ``extraction_version`` = int, bumped on signature-changing
74
+ algorithm updates
75
+ * class-level ``supported_coefficients`` = tuple of coefficient names
76
+ * ``dry_run(config)`` returns a DryRunManifest
77
+ * ``extract(config)`` returns a Signature
78
+ """
79
+
80
+ stream: SubstrateStream
81
+ extraction_version: int = 1
82
+ supported_coefficients: tuple[str, ...] = ()
83
+
84
+ @abstractmethod
85
+ def dry_run(self, config: dict[str, Any]) -> DryRunManifest:
86
+ """Enumerate what WOULD be read. Pure - no side effects on substrate."""
87
+
88
+ @abstractmethod
89
+ def extract(self, config: dict[str, Any]) -> Signature:
90
+ """Perform the read + extract signature. Discard content before return.
91
+
92
+ Adapters MUST NOT retain substrate content beyond this method's scope.
93
+ Content lives in local memory during extraction and is released on
94
+ return; only the Signature crosses the method boundary.
95
+ """
96
+
97
+ def provenance(self, config: dict[str, Any]) -> ProvenanceClass:
98
+ """Classify this read per D-IaI-1.2. Override for passive subscribers.
99
+
100
+ Default: ACTIVE (user-initiated `alter atlas read`).
101
+ """
102
+ return ProvenanceClass.ACTIVE