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,608 @@
1
+ """WeaveIntentWriter - projects intent signals into the weave-intent.jsonl stream.
2
+
3
+ D-WEAVE-VC-2 §8 items 1 + 2 (S1 + S2).
4
+
5
+ Owns ``~/.local/share/alter-runtime/weave-intent.jsonl``, writing ``intent.*``
6
+ records with explicit ``ttl_seconds`` + ``expires_at`` fields.
7
+
8
+ Two producers feed this writer:
9
+
10
+ (a) **cc-intent.sh hook** (built by a separate agent, NOT this module).
11
+ The hook runs on PreToolUse Edit|Write inside Claude Code and appends
12
+ JSONL records of kind ``intent.edit`` directly to ``weave-intent.jsonl``
13
+ with fields::
14
+
15
+ {kind, source, session_id, handle, file_path, anchor, ts (ISO-8601 string), ttl_seconds}
16
+
17
+ The writer tails this file for records it did NOT write itself, then
18
+ enriches them with a ``semantic_unit`` field via the tree-sitter resolver
19
+ (S2) and re-appends the enriched record.
20
+
21
+ (b) **WorktreeWatcher bus events** (kind ``"worktree_edit"`` on ``local.signal``).
22
+ Plain-editor saves with no ``intent.edit`` partner emit a coarse
23
+ ``worktree_edit`` record — modelled as a dangling edge per D-WEAVE-VC-2
24
+ §3 degradation.
25
+
26
+ Ingestion design decision
27
+ --------------------------
28
+
29
+ The writer must NOT circularly tail the same file it appends to. The design
30
+ chosen is a ``source`` field sentinel:
31
+
32
+ 1. Records written by THIS writer carry ``"source": "weave_intent_writer"``.
33
+ 2. The tailer skips any record whose ``source`` matches this sentinel, so
34
+ enriched re-appended records are not re-processed.
35
+ 3. Hook-originated records carry ``"source": "cc_intent_hook"`` (or omit
36
+ ``source`` entirely) — the tailer picks those up for enrichment.
37
+
38
+ This means a single file carries both raw hook records and enriched records.
39
+ Consumers that want only enriched records filter on ``semantic_unit`` presence;
40
+ consumers that want the full audit trail read everything.
41
+
42
+ Advisory-only invariant
43
+ ------------------------
44
+
45
+ Nothing in this module blocks, leases, or gates a write. The writer records;
46
+ it never arbitrates. See D-WEAVE-VC-2 §2 advisory doctrine.
47
+
48
+ TTL
49
+ ---
50
+
51
+ Default TTL is 900 seconds (15 minutes), configurable via
52
+ ``ALTER_RUNTIME_WEAVE_INTENT_TTL`` env var or constructor argument. Every
53
+ record carries ``ttl_seconds`` (integer) and ``expires_at`` (ISO-8601 UTC
54
+ datetime string) for consumer convenience.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import asyncio
60
+ import contextlib
61
+ import errno
62
+ import fcntl
63
+ import json
64
+ import logging
65
+ import os
66
+ from datetime import datetime, timedelta, timezone
67
+ from pathlib import Path
68
+
69
+ # TYPE_CHECKING import for EventBus avoids circular dependency at runtime
70
+ from typing import TYPE_CHECKING, Any
71
+
72
+ from alter_runtime.config import DaemonConfig, data_dir
73
+ from alter_runtime.daemon import Component
74
+
75
+ if TYPE_CHECKING:
76
+ from alter_runtime.subscribers.bus import EventBus
77
+
78
+ __all__ = [
79
+ "WEAVE_INTENT_FILENAME",
80
+ "DEFAULT_TTL_SECONDS",
81
+ "WRITER_SOURCE_SENTINEL",
82
+ "WeaveIntentWriter",
83
+ ]
84
+
85
+ logger = logging.getLogger("alter_runtime.subscribers.weave_intent_writer")
86
+
87
+ #: Filename for the weave-intent JSONL (within ``data_dir()``).
88
+ WEAVE_INTENT_FILENAME: str = "weave-intent.jsonl"
89
+
90
+ #: Default intent TTL in seconds — 15-minute hypothesis per D-WEAVE-VC-2 §10 Q1.
91
+ #: Override via ALTER_RUNTIME_WEAVE_INTENT_TTL env var.
92
+ DEFAULT_TTL_SECONDS: int = 900
93
+
94
+ #: Source sentinel written on every record this module appends.
95
+ #: The tailer skips records with this sentinel to avoid circular re-processing.
96
+ WRITER_SOURCE_SENTINEL: str = "weave_intent_writer"
97
+
98
+ #: How often (seconds) the tailer polls the intent file for new hook records.
99
+ _TAIL_POLL_INTERVAL: float = 0.5
100
+
101
+ #: Rotation threshold: 5 MiB
102
+ ROTATION_THRESHOLD_BYTES: int = 5 * 1024 * 1024
103
+
104
+
105
+ class WeaveIntentWriter(Component):
106
+ """Subscribes to local.signal worktree_edit events and tails weave-intent.jsonl
107
+ for cc-intent hook records, writing enriched intent.* records.
108
+
109
+ Parameters
110
+ ----------
111
+ config:
112
+ Loaded :class:`DaemonConfig`.
113
+ bus:
114
+ Shared :class:`EventBus`. Subscribes to ``local.signal``.
115
+ intent_path:
116
+ Override the JSONL path. Tests redirect writes to ``tmp_path``.
117
+ ttl_seconds:
118
+ Intent TTL in seconds. Defaults to ``DEFAULT_TTL_SECONDS`` (900s).
119
+ tail_poll_interval:
120
+ How often (seconds) to poll the intent file for new hook records.
121
+ """
122
+
123
+ name = "weave_intent_writer"
124
+
125
+ def __init__(
126
+ self,
127
+ config: DaemonConfig,
128
+ bus: EventBus,
129
+ *,
130
+ intent_path: Path | None = None,
131
+ ttl_seconds: int | None = None,
132
+ tail_poll_interval: float = _TAIL_POLL_INTERVAL,
133
+ ) -> None:
134
+ self._config = config
135
+ self._bus = bus
136
+
137
+ self._intent_path: Path = (
138
+ intent_path if intent_path is not None else data_dir() / WEAVE_INTENT_FILENAME
139
+ )
140
+
141
+ # TTL: constructor arg > env var > default
142
+ if ttl_seconds is not None:
143
+ self._ttl_seconds = int(ttl_seconds)
144
+ else:
145
+ env_val = os.environ.get("ALTER_RUNTIME_WEAVE_INTENT_TTL")
146
+ if env_val:
147
+ try:
148
+ self._ttl_seconds = int(env_val)
149
+ except (ValueError, TypeError):
150
+ logger.warning(
151
+ "weave_intent_writer: invalid ALTER_RUNTIME_WEAVE_INTENT_TTL=%r — "
152
+ "using default %ds",
153
+ env_val,
154
+ DEFAULT_TTL_SECONDS,
155
+ )
156
+ self._ttl_seconds = DEFAULT_TTL_SECONDS
157
+ else:
158
+ self._ttl_seconds = DEFAULT_TTL_SECONDS
159
+
160
+ self._tail_poll_interval = tail_poll_interval
161
+ self._shutdown_event = asyncio.Event()
162
+ self._write_lock = asyncio.Lock()
163
+ #: Byte offset into the intent file up to which we have already processed
164
+ #: hook-originated records.
165
+ self._tail_offset: int = 0
166
+
167
+ # ------------------------------------------------------------------
168
+ # Component lifecycle
169
+ # ------------------------------------------------------------------
170
+
171
+ async def run(self) -> None:
172
+ self._bus.subscribe("local.signal", self._handle_bus_event)
173
+ logger.info(
174
+ "weave_intent_writer started path=%s ttl=%ds",
175
+ self._intent_path,
176
+ self._ttl_seconds,
177
+ )
178
+
179
+ # Seed the tail offset to the current end-of-file so we don't
180
+ # re-process records that were written before this daemon started.
181
+ self._tail_offset = self._current_file_size()
182
+
183
+ tail_task = asyncio.create_task(self._tail_loop())
184
+ try:
185
+ await self._shutdown_event.wait()
186
+ except asyncio.CancelledError:
187
+ raise
188
+ finally:
189
+ with contextlib.suppress(Exception):
190
+ self._bus.unsubscribe("local.signal", self._handle_bus_event)
191
+ tail_task.cancel()
192
+ with contextlib.suppress(asyncio.CancelledError, Exception):
193
+ await tail_task
194
+ logger.info("weave_intent_writer stopped")
195
+
196
+ async def stop(self) -> None:
197
+ self._shutdown_event.set()
198
+
199
+ # ------------------------------------------------------------------
200
+ # Bus event handler (producer b — WorktreeWatcher)
201
+ # ------------------------------------------------------------------
202
+
203
+ async def _handle_bus_event(self, event: dict[str, Any]) -> None:
204
+ """Handle local.signal events from the bus."""
205
+ if not isinstance(event, dict):
206
+ return
207
+ if event.get("kind") != "worktree_edit":
208
+ return
209
+
210
+ payload = event.get("payload", {})
211
+ if not isinstance(payload, dict):
212
+ return
213
+
214
+ file_path = payload.get("file_path", "")
215
+ rel_path = payload.get("rel_path", "")
216
+ ts = payload.get("ts") or datetime.now(timezone.utc).isoformat()
217
+
218
+ record = self._build_worktree_edit_record(
219
+ file_path=file_path,
220
+ rel_path=rel_path,
221
+ ts=ts,
222
+ )
223
+ await self._append_record(record)
224
+
225
+ def _build_worktree_edit_record(
226
+ self,
227
+ file_path: str,
228
+ rel_path: str,
229
+ ts: str,
230
+ ) -> dict[str, Any]:
231
+ """Build a coarse worktree_edit intent record (dangling edge)."""
232
+ expires_at = _compute_expires_at(ts, self._ttl_seconds)
233
+ return {
234
+ "kind": "intent.worktree_edit",
235
+ "file_path": file_path,
236
+ "rel_path": rel_path,
237
+ "ts": ts,
238
+ "ttl_seconds": self._ttl_seconds,
239
+ "expires_at": expires_at,
240
+ "source": WRITER_SOURCE_SENTINEL,
241
+ # Dangling edge — no session_id / handle / anchor from a plain save.
242
+ # Consumers that need a paired intent.edit record check for presence
243
+ # of session_id. This coarse record enables file-level advisory even
244
+ # when the CC hook is not active.
245
+ "dangling": True,
246
+ }
247
+
248
+ # ------------------------------------------------------------------
249
+ # Tailer loop (producer a — cc-intent.sh hook records)
250
+ # ------------------------------------------------------------------
251
+
252
+ async def _tail_loop(self) -> None:
253
+ """Poll the intent file on an interval and process new hook records."""
254
+ while not self._shutdown_event.is_set():
255
+ try:
256
+ await self._process_new_hook_records()
257
+ except asyncio.CancelledError:
258
+ raise
259
+ except Exception as exc: # pragma: no cover
260
+ logger.warning("weave_intent_writer: tail loop error: %s", exc)
261
+
262
+ try:
263
+ await asyncio.wait_for(
264
+ asyncio.shield(self._shutdown_event.wait()),
265
+ timeout=self._tail_poll_interval,
266
+ )
267
+ return # shutdown requested
268
+ except (TimeoutError, asyncio.TimeoutError):
269
+ pass # normal poll interval expiry
270
+
271
+ async def _process_new_hook_records(self) -> None:
272
+ """Read new bytes from the intent file since the last tail offset
273
+ and process any hook-originated records.
274
+
275
+ Circular re-processing is prevented by skipping records whose
276
+ ``source`` field matches ``WRITER_SOURCE_SENTINEL``.
277
+ """
278
+ if not self._intent_path.exists():
279
+ return
280
+
281
+ try:
282
+ current_size = self._intent_path.stat().st_size
283
+ except OSError:
284
+ return
285
+
286
+ if current_size <= self._tail_offset:
287
+ # File shrunk (rotation) — reset to 0
288
+ if current_size < self._tail_offset:
289
+ logger.debug(
290
+ "weave_intent_writer: file shrunk (rotation?) "
291
+ "old_offset=%d new_size=%d — resetting",
292
+ self._tail_offset,
293
+ current_size,
294
+ )
295
+ self._tail_offset = 0
296
+ return
297
+
298
+ try:
299
+ with open(self._intent_path, "rb") as fh:
300
+ fh.seek(self._tail_offset)
301
+ chunk = fh.read(current_size - self._tail_offset)
302
+ except OSError as exc:
303
+ logger.warning("weave_intent_writer: tail read failed: %s", exc)
304
+ return
305
+
306
+ self._tail_offset = current_size
307
+
308
+ if not chunk:
309
+ return
310
+
311
+ lines = chunk.decode("utf-8", errors="replace").splitlines()
312
+ for line in lines:
313
+ line = line.strip()
314
+ if not line:
315
+ continue
316
+ try:
317
+ record = json.loads(line)
318
+ except json.JSONDecodeError:
319
+ continue
320
+ if not isinstance(record, dict):
321
+ continue
322
+
323
+ # Skip records this writer produced (circular-processing guard)
324
+ if record.get("source") == WRITER_SOURCE_SENTINEL:
325
+ continue
326
+
327
+ # Only process hook-originated intent.edit records
328
+ if record.get("kind") not in ("intent.edit", None):
329
+ # Accept records that lack a kind (legacy hook format) and
330
+ # explicit intent.edit records.
331
+ if record.get("kind") is not None:
332
+ continue
333
+
334
+ try:
335
+ await self._enrich_and_append(record)
336
+ except Exception as exc: # pragma: no cover
337
+ logger.warning(
338
+ "weave_intent_writer: failed to enrich record %r: %s — skipping",
339
+ record,
340
+ exc,
341
+ )
342
+
343
+ async def _enrich_and_append(self, raw: dict[str, Any]) -> None:
344
+ """Enrich a raw hook record with semantic_unit (S2) and re-append."""
345
+ file_path = raw.get("file_path", "")
346
+ anchor = raw.get("anchor")
347
+
348
+ # Normalise ts before any downstream use — accepts ISO string, epoch
349
+ # int/float, or None. This is the primary fix for the AttributeError
350
+ # raised when cc-intent.sh (legacy) emitted ts as an epoch integer and
351
+ # _compute_expires_at called ts.rstrip("Z") on it.
352
+ ts = _normalise_ts(raw.get("ts"))
353
+
354
+ # S2: resolve semantic unit — must NEVER block or raise into daemon
355
+ semantic_unit = _resolve_semantic_unit(file_path, anchor)
356
+
357
+ # Accept both ttl_seconds (new contract) and ttl (legacy fallback) so
358
+ # the writer remains resilient to older hook versions in the wild.
359
+ ttl = raw.get("ttl_seconds", raw.get("ttl", self._ttl_seconds))
360
+ try:
361
+ ttl = int(ttl)
362
+ except (TypeError, ValueError):
363
+ ttl = self._ttl_seconds
364
+
365
+ expires_at = _compute_expires_at(ts, ttl)
366
+
367
+ enriched: dict[str, Any] = {
368
+ "kind": "intent.edit",
369
+ "session_id": raw.get("session_id"),
370
+ "handle": raw.get("handle"),
371
+ "file_path": file_path,
372
+ "anchor": anchor,
373
+ "ts": ts,
374
+ "ttl_seconds": ttl,
375
+ "expires_at": expires_at,
376
+ "source": WRITER_SOURCE_SENTINEL,
377
+ }
378
+ if semantic_unit is not None:
379
+ enriched["semantic_unit"] = {
380
+ "symbol": semantic_unit.symbol,
381
+ "kind": semantic_unit.kind,
382
+ "qualified_name": semantic_unit.qualified_name,
383
+ "file_path": semantic_unit.file_path,
384
+ "start_line": semantic_unit.start_line,
385
+ "end_line": semantic_unit.end_line,
386
+ }
387
+ else:
388
+ # Degrade to file-level — record absence explicitly
389
+ enriched["semantic_unit"] = None
390
+
391
+ await self._append_record(enriched)
392
+
393
+ # ------------------------------------------------------------------
394
+ # File operations
395
+ # ------------------------------------------------------------------
396
+
397
+ async def _append_record(self, record: dict[str, Any]) -> None:
398
+ """Serialise and append one record to the intent JSONL."""
399
+ async with self._write_lock:
400
+ line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
401
+ try:
402
+ self._maybe_rotate()
403
+ except OSError as exc:
404
+ logger.warning("weave_intent_writer: rotation failed: %s", exc)
405
+
406
+ try:
407
+ self._append_line(line)
408
+ except OSError as exc:
409
+ logger.warning("weave_intent_writer: append failed: %s — dropping record", exc)
410
+
411
+ def _ensure_parent(self) -> None:
412
+ parent = self._intent_path.parent
413
+ if not parent.exists():
414
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
415
+ with contextlib.suppress(OSError):
416
+ os.chmod(parent, 0o700)
417
+
418
+ def _append_line(self, line: str) -> None:
419
+ self._ensure_parent()
420
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
421
+ fd = os.open(self._intent_path, flags, 0o600)
422
+ try:
423
+ with contextlib.suppress(OSError):
424
+ os.fchmod(fd, 0o600)
425
+ try:
426
+ fcntl.flock(fd, fcntl.LOCK_EX)
427
+ except OSError as exc:
428
+ if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
429
+ raise
430
+ try:
431
+ os.write(fd, line.encode("utf-8") + b"\n")
432
+ os.fsync(fd)
433
+ finally:
434
+ with contextlib.suppress(OSError):
435
+ fcntl.flock(fd, fcntl.LOCK_UN)
436
+ finally:
437
+ os.close(fd)
438
+
439
+ def _maybe_rotate(self) -> None:
440
+ try:
441
+ size = self._intent_path.stat().st_size
442
+ except FileNotFoundError:
443
+ return
444
+ if size <= ROTATION_THRESHOLD_BYTES:
445
+ return
446
+ rotated = self._intent_path.parent / (WEAVE_INTENT_FILENAME + ".1")
447
+ os.replace(self._intent_path, rotated)
448
+ # Reset tail offset — after rotation the active file starts at 0.
449
+ self._tail_offset = 0
450
+ logger.info(
451
+ "weave_intent_writer: rotated %s -> %s (size=%d)",
452
+ self._intent_path,
453
+ rotated,
454
+ size,
455
+ )
456
+
457
+ def _current_file_size(self) -> int:
458
+ try:
459
+ return self._intent_path.stat().st_size
460
+ except OSError:
461
+ return 0
462
+
463
+ # ------------------------------------------------------------------
464
+ # Test introspection
465
+ # ------------------------------------------------------------------
466
+
467
+ @property
468
+ def intent_path(self) -> Path:
469
+ return self._intent_path
470
+
471
+ @property
472
+ def ttl_seconds(self) -> int:
473
+ return self._ttl_seconds
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # S2: semantic-unit resolver shim
478
+ # ---------------------------------------------------------------------------
479
+
480
+
481
+ def _resolve_semantic_unit(
482
+ file_path: str,
483
+ anchor: str | None,
484
+ ) -> Any | None:
485
+ """Call the tree-sitter resolver and return a SemanticUnit or None.
486
+
487
+ Resolution failure NEVER blocks or raises — it degrades to None
488
+ (file-level intent) per the S2 constraint.
489
+
490
+ Parameters
491
+ ----------
492
+ file_path:
493
+ Absolute or relative path to the Python source file being edited.
494
+ anchor:
495
+ The head of the old_string / Write target from the CC hook.
496
+ Used to derive an approximate line number when an explicit
497
+ line_range is not available. When absent, defaults to line (1, 1)
498
+ which returns the module-level unit.
499
+ """
500
+ try:
501
+ from alter_runtime.weave.resolver import resolve as _resolve
502
+
503
+ # Derive a coarse line range from the anchor text if present.
504
+ # The anchor is the first line of the CC old_string, so we
505
+ # search for it in the file and use its 1-based line number.
506
+ line_range = _anchor_to_line_range(file_path, anchor)
507
+ return _resolve(file_path, line_range)
508
+
509
+ except Exception as exc: # pragma: no cover
510
+ logger.debug(
511
+ "weave_intent_writer: resolve failed for %r: %s — degrading to file-level",
512
+ file_path,
513
+ exc,
514
+ )
515
+ return None
516
+
517
+
518
+ def _anchor_to_line_range(file_path: str, anchor: str | None) -> tuple[int, int]:
519
+ """Return a best-guess (start, end) line range for the anchor text.
520
+
521
+ If the anchor is not present or cannot be found in the file, returns (1, 1)
522
+ which causes the resolver to return the module-level unit. This is the
523
+ correct degradation — a missing anchor gives file-level intent, not an error.
524
+ """
525
+ if not anchor or not file_path:
526
+ return (1, 1)
527
+
528
+ try:
529
+ p = Path(file_path)
530
+ if not p.exists():
531
+ return (1, 1)
532
+
533
+ # Only scan the first line of the anchor for speed — exact match wins.
534
+ anchor_first_line = anchor.splitlines()[0].strip() if anchor.strip() else ""
535
+ if not anchor_first_line:
536
+ return (1, 1)
537
+
538
+ content = p.read_text(encoding="utf-8", errors="replace")
539
+ for lineno, line in enumerate(content.splitlines(), start=1):
540
+ if anchor_first_line in line:
541
+ return (lineno, lineno)
542
+
543
+ return (1, 1)
544
+
545
+ except Exception:
546
+ return (1, 1)
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # TTL helper
551
+ # ---------------------------------------------------------------------------
552
+
553
+
554
+ def _normalise_ts(value: Any) -> str:
555
+ """Return an ISO-8601 UTC string from an ISO string, epoch int/float, or None.
556
+
557
+ Parameters
558
+ ----------
559
+ value:
560
+ Accepted inputs:
561
+ - ISO-8601 string (with or without trailing ``Z``): returned as-is
562
+ after stripping the ``Z`` and re-attaching UTC timezone.
563
+ - ``int`` or ``float``: treated as a POSIX epoch and converted via
564
+ ``datetime.fromtimestamp``.
565
+ - ``None`` or anything unparseable: falls back to now (UTC).
566
+ """
567
+ if value is None:
568
+ return datetime.now(timezone.utc).isoformat()
569
+
570
+ if isinstance(value, (int, float)):
571
+ try:
572
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
573
+ except (OSError, OverflowError, ValueError):
574
+ return datetime.now(timezone.utc).isoformat()
575
+
576
+ if isinstance(value, str):
577
+ try:
578
+ ts_stripped = value.rstrip("Z")
579
+ if "+" in ts_stripped:
580
+ dt = datetime.fromisoformat(value)
581
+ else:
582
+ dt = datetime.fromisoformat(ts_stripped).replace(tzinfo=timezone.utc)
583
+ return dt.isoformat()
584
+ except (ValueError, TypeError):
585
+ return datetime.now(timezone.utc).isoformat()
586
+
587
+ # Unknown type — fall back to now
588
+ return datetime.now(timezone.utc).isoformat()
589
+
590
+
591
+ def _compute_expires_at(ts: Any, ttl_seconds: int) -> str:
592
+ """Return an ISO-8601 UTC datetime string for ``ts + ttl_seconds``.
593
+
594
+ Accepts ``ts`` as an ISO-8601 string, an epoch int/float, or None.
595
+ Falls back to now + ttl_seconds when ``ts`` cannot be parsed.
596
+ """
597
+ try:
598
+ normalised = _normalise_ts(ts)
599
+ ts_stripped = normalised.rstrip("Z")
600
+ if "+" in ts_stripped:
601
+ dt = datetime.fromisoformat(normalised)
602
+ else:
603
+ dt = datetime.fromisoformat(ts_stripped).replace(tzinfo=timezone.utc)
604
+ except (ValueError, TypeError, AttributeError):
605
+ dt = datetime.now(timezone.utc)
606
+
607
+ expires = dt + timedelta(seconds=ttl_seconds)
608
+ return expires.isoformat()