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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|