strange-loops 0.1.0__tar.gz

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 (82) hide show
  1. strange_loops-0.1.0/.gitignore +27 -0
  2. strange_loops-0.1.0/PKG-INFO +20 -0
  3. strange_loops-0.1.0/README.md +8 -0
  4. strange_loops-0.1.0/apps/loops/src/loops/__init__.py +0 -0
  5. strange_loops-0.1.0/apps/loops/src/loops/__main__.py +7 -0
  6. strange_loops-0.1.0/apps/loops/src/loops/commands/__init__.py +1 -0
  7. strange_loops-0.1.0/apps/loops/src/loops/commands/fetch.py +207 -0
  8. strange_loops-0.1.0/apps/loops/src/loops/commands/identity.py +222 -0
  9. strange_loops-0.1.0/apps/loops/src/loops/commands/pop.py +347 -0
  10. strange_loops-0.1.0/apps/loops/src/loops/commands/store.py +125 -0
  11. strange_loops-0.1.0/apps/loops/src/loops/commands/vertices.py +127 -0
  12. strange_loops-0.1.0/apps/loops/src/loops/lens_resolver.py +166 -0
  13. strange_loops-0.1.0/apps/loops/src/loops/lenses/CLAUDE.md +111 -0
  14. strange_loops-0.1.0/apps/loops/src/loops/lenses/__init__.py +1 -0
  15. strange_loops-0.1.0/apps/loops/src/loops/lenses/_helpers.py +103 -0
  16. strange_loops-0.1.0/apps/loops/src/loops/lenses/compile.py +134 -0
  17. strange_loops-0.1.0/apps/loops/src/loops/lenses/fold.py +218 -0
  18. strange_loops-0.1.0/apps/loops/src/loops/lenses/gist.py +133 -0
  19. strange_loops-0.1.0/apps/loops/src/loops/lenses/pop.py +72 -0
  20. strange_loops-0.1.0/apps/loops/src/loops/lenses/run.py +134 -0
  21. strange_loops-0.1.0/apps/loops/src/loops/lenses/store.py +326 -0
  22. strange_loops-0.1.0/apps/loops/src/loops/lenses/stream.py +177 -0
  23. strange_loops-0.1.0/apps/loops/src/loops/lenses/sync.py +141 -0
  24. strange_loops-0.1.0/apps/loops/src/loops/lenses/test.py +60 -0
  25. strange_loops-0.1.0/apps/loops/src/loops/lenses/validate.py +64 -0
  26. strange_loops-0.1.0/apps/loops/src/loops/lenses/vertices.py +78 -0
  27. strange_loops-0.1.0/apps/loops/src/loops/main.py +2586 -0
  28. strange_loops-0.1.0/apps/loops/src/loops/palette.py +109 -0
  29. strange_loops-0.1.0/apps/loops/src/loops/pop_store.py +118 -0
  30. strange_loops-0.1.0/apps/loops/src/loops/tui/__init__.py +5 -0
  31. strange_loops-0.1.0/apps/loops/src/loops/tui/store_app.py +503 -0
  32. strange_loops-0.1.0/libs/atoms/src/atoms/__init__.py +103 -0
  33. strange_loops-0.1.0/libs/atoms/src/atoms/boundary.py +59 -0
  34. strange_loops-0.1.0/libs/atoms/src/atoms/engine.py +207 -0
  35. strange_loops-0.1.0/libs/atoms/src/atoms/facet.py +34 -0
  36. strange_loops-0.1.0/libs/atoms/src/atoms/fact.py +92 -0
  37. strange_loops-0.1.0/libs/atoms/src/atoms/fold.py +245 -0
  38. strange_loops-0.1.0/libs/atoms/src/atoms/fold_state.py +106 -0
  39. strange_loops-0.1.0/libs/atoms/src/atoms/parse.py +638 -0
  40. strange_loops-0.1.0/libs/atoms/src/atoms/protocol.py +28 -0
  41. strange_loops-0.1.0/libs/atoms/src/atoms/sequential.py +56 -0
  42. strange_loops-0.1.0/libs/atoms/src/atoms/source.py +231 -0
  43. strange_loops-0.1.0/libs/atoms/src/atoms/spec.py +119 -0
  44. strange_loops-0.1.0/libs/atoms/src/atoms/types.py +145 -0
  45. strange_loops-0.1.0/libs/engine/src/engine/__init__.py +149 -0
  46. strange_loops-0.1.0/libs/engine/src/engine/cadence.py +111 -0
  47. strange_loops-0.1.0/libs/engine/src/engine/compiler.py +994 -0
  48. strange_loops-0.1.0/libs/engine/src/engine/executor.py +300 -0
  49. strange_loops-0.1.0/libs/engine/src/engine/file_store.py +128 -0
  50. strange_loops-0.1.0/libs/engine/src/engine/file_writer.py +38 -0
  51. strange_loops-0.1.0/libs/engine/src/engine/forward.py +26 -0
  52. strange_loops-0.1.0/libs/engine/src/engine/lens.py +56 -0
  53. strange_loops-0.1.0/libs/engine/src/engine/loop.py +155 -0
  54. strange_loops-0.1.0/libs/engine/src/engine/observer.py +48 -0
  55. strange_loops-0.1.0/libs/engine/src/engine/peer.py +165 -0
  56. strange_loops-0.1.0/libs/engine/src/engine/program.py +161 -0
  57. strange_loops-0.1.0/libs/engine/src/engine/projection.py +98 -0
  58. strange_loops-0.1.0/libs/engine/src/engine/replay.py +33 -0
  59. strange_loops-0.1.0/libs/engine/src/engine/source_protocol.py +54 -0
  60. strange_loops-0.1.0/libs/engine/src/engine/sqlite_store.py +225 -0
  61. strange_loops-0.1.0/libs/engine/src/engine/store.py +215 -0
  62. strange_loops-0.1.0/libs/engine/src/engine/store_reader.py +300 -0
  63. strange_loops-0.1.0/libs/engine/src/engine/stream.py +73 -0
  64. strange_loops-0.1.0/libs/engine/src/engine/tailer.py +89 -0
  65. strange_loops-0.1.0/libs/engine/src/engine/tick.py +61 -0
  66. strange_loops-0.1.0/libs/engine/src/engine/vertex.py +714 -0
  67. strange_loops-0.1.0/libs/engine/src/engine/vertex_reader.py +1139 -0
  68. strange_loops-0.1.0/libs/lang/src/lang/__init__.py +141 -0
  69. strange_loops-0.1.0/libs/lang/src/lang/ast.py +565 -0
  70. strange_loops-0.1.0/libs/lang/src/lang/errors.py +59 -0
  71. strange_loops-0.1.0/libs/lang/src/lang/loader.py +796 -0
  72. strange_loops-0.1.0/libs/lang/src/lang/population.py +462 -0
  73. strange_loops-0.1.0/libs/lang/src/lang/validator.py +365 -0
  74. strange_loops-0.1.0/libs/store/src/store/__init__.py +29 -0
  75. strange_loops-0.1.0/libs/store/src/store/_conn.py +77 -0
  76. strange_loops-0.1.0/libs/store/src/store/_transport_local.py +68 -0
  77. strange_loops-0.1.0/libs/store/src/store/compact.py +66 -0
  78. strange_loops-0.1.0/libs/store/src/store/merge.py +98 -0
  79. strange_loops-0.1.0/libs/store/src/store/receive.py +85 -0
  80. strange_loops-0.1.0/libs/store/src/store/slice.py +143 -0
  81. strange_loops-0.1.0/libs/store/src/store/transport.py +150 -0
  82. strange_loops-0.1.0/pyproject.toml +57 -0
@@ -0,0 +1,27 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .uv-cache/
8
+ uv.lock
9
+ *.jsonl
10
+ .coverage
11
+ .pytest_cache/
12
+ .subtask/
13
+ .tadpole/
14
+ .claude/todos/
15
+ .obsidian/
16
+ .DS_Store
17
+ /data/
18
+ *.db
19
+ *.db-shm
20
+ *.db-wal
21
+ *.gif
22
+ *.tape
23
+ check.vertex
24
+ session.vertex
25
+ !config/**/session.vertex
26
+ !config/**/check.vertex
27
+ .ruff_cache/
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: strange-loops
3
+ Version: 0.1.0
4
+ Summary: A system for focusing attention
5
+ Author-email: Kyle Gruel <kylegruel@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: ckdl>=1.0
9
+ Requires-Dist: painted>=0.1.1
10
+ Requires-Dist: sqlite-ulid
11
+ Description-Content-Type: text/markdown
12
+
13
+ # strange-loops
14
+
15
+ A system for focusing attention. Observations flow in, accumulate into state,
16
+ boundaries resolve, conclusions flow out. The conclusions re-enter as new
17
+ observations. The loop closes through the observer.
18
+
19
+ # Caveat
20
+ This is AI slop. It exists here because I was asked to share it, not because it is in a state to share and faffing about making it non-public seemed silly.
@@ -0,0 +1,8 @@
1
+ # strange-loops
2
+
3
+ A system for focusing attention. Observations flow in, accumulate into state,
4
+ boundaries resolve, conclusions flow out. The conclusions re-enter as new
5
+ observations. The loop closes through the observer.
6
+
7
+ # Caveat
8
+ This is AI slop. It exists here because I was asked to share it, not because it is in a state to share and faffing about making it non-public seemed silly.
File without changes
@@ -0,0 +1,7 @@
1
+ """Allow running as python -m loops."""
2
+
3
+ import sys
4
+
5
+ from .main import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Store commands — data fetch layer."""
@@ -0,0 +1,207 @@
1
+ """Data retrieval — fold (collapsed state) and stream (event history).
2
+
3
+ Supports key drill-down via ``kind/key`` syntax on the ``--kind`` flag:
4
+ ``--kind thread/fold-state-types`` filters to the single folded item
5
+ (fold) or facts matching the key field value (stream).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from atoms import FoldItem, FoldState
17
+
18
+
19
+ def _parse_duration(s: str) -> float:
20
+ """Parse duration string like '7d', '24h', '1h' to seconds."""
21
+ m = re.match(r"^(\d+)([dhms])$", s)
22
+ if not m:
23
+ raise ValueError(f"Invalid duration: {s!r} (expected e.g. '7d', '24h', '1h')")
24
+ value = int(m.group(1))
25
+ unit = m.group(2)
26
+ multipliers = {"d": 86400, "h": 3600, "m": 60, "s": 1}
27
+ return value * multipliers[unit]
28
+
29
+
30
+ def _split_kind_key(kind: str | None) -> tuple[str | None, str | None]:
31
+ """Split ``kind/key`` into (kind, key). Plain kind returns (kind, None)."""
32
+ if kind is None:
33
+ return None, None
34
+ if "/" in kind:
35
+ k, v = kind.split("/", 1)
36
+ return k, v
37
+ return kind, None
38
+
39
+
40
+ def _get_key_field(vertex_path: Path, kind: str) -> str | None:
41
+ """Look up the key field for a kind from the vertex's fold declarations."""
42
+ from lang import parse_vertex_file
43
+ from lang.ast import FoldBy
44
+
45
+ ast = parse_vertex_file(vertex_path)
46
+ loop_def = ast.loops.get(kind)
47
+ if loop_def and loop_def.folds:
48
+ fold_decl = loop_def.folds[0]
49
+ if isinstance(fold_decl.op, FoldBy):
50
+ return fold_decl.op.key_field
51
+ return None
52
+
53
+
54
+ def fetch_fold(
55
+ vertex_path: Path,
56
+ kind: str | None = None,
57
+ observer: str | None = None,
58
+ ) -> "FoldState":
59
+ """Fetch fold state, with optional key drill-down.
60
+
61
+ Supports ``kind/key`` syntax: ``thread/fold-state-types`` filters
62
+ to the single item whose key field matches. The fold section is
63
+ preserved (one item instead of many) so lenses render normally.
64
+ """
65
+ from atoms import FoldSection, FoldState
66
+ from engine import vertex_fold
67
+
68
+ kind_filter, key_filter = _split_kind_key(kind)
69
+ state = vertex_fold(vertex_path, observer=observer, kind=kind_filter)
70
+
71
+ if key_filter is None:
72
+ return state
73
+
74
+ # Filter sections to items matching the key value
75
+ filtered: list[FoldSection] = []
76
+ for section in state.sections:
77
+ if section.kind != kind_filter:
78
+ continue
79
+ matches = tuple(
80
+ item for item in section.items
81
+ if _item_matches_key(item, section.key_field, key_filter)
82
+ )
83
+ if matches:
84
+ filtered.append(FoldSection(
85
+ kind=section.kind,
86
+ items=matches,
87
+ sections=section.sections,
88
+ fold_type=section.fold_type,
89
+ key_field=section.key_field,
90
+ ))
91
+
92
+ return FoldState(sections=tuple(filtered), vertex=state.vertex)
93
+
94
+
95
+ def _item_matches_key(item: "FoldItem", key_field: str | None, key: str) -> bool:
96
+ """Check if a fold item matches a key value.
97
+
98
+ Tries key_field first, then common label fields. Case-insensitive.
99
+ """
100
+ candidates = [key_field] if key_field else []
101
+ candidates.extend(["topic", "name", "title", "summary"])
102
+
103
+ for field in candidates:
104
+ if field and field in item.payload:
105
+ val = str(item.payload[field])
106
+ if val.lower() == key.lower():
107
+ return True
108
+ return False
109
+
110
+
111
+ def fetch_stream(
112
+ vertex_path: Path,
113
+ *,
114
+ query: str | None = None,
115
+ kind: str | None = None,
116
+ since: str | None = None,
117
+ observer: str | None = None,
118
+ ) -> dict:
119
+ """Fetch event stream with three orthogonal filters.
120
+
121
+ Unifies log + search into a single fetch. When *query* is provided,
122
+ uses FTS5 search; otherwise returns raw facts in reverse-chrono order.
123
+
124
+ Supports ``kind/key`` drill-down: ``--kind thread/fold-state-types``
125
+ returns only facts whose key field payload matches. When drilling down,
126
+ time window defaults to all history (not 7d).
127
+
128
+ Returns ``{"facts": list[dict], "fold_meta": dict, "vertex": str}``.
129
+ """
130
+ from engine import vertex_facts, vertex_search
131
+ from lang import parse_vertex_file
132
+ from lang.ast import FoldBy
133
+
134
+ kind_filter, key_filter = _split_kind_key(kind)
135
+
136
+ # When drilling into a specific item, default to all history
137
+ default_since = "7d" if key_filter is None else "3650d"
138
+ since_secs = _parse_duration(since or default_since)
139
+ now = datetime.now(timezone.utc)
140
+ since_ts = (now - timedelta(seconds=since_secs)).timestamp()
141
+
142
+ if query:
143
+ facts = vertex_search(
144
+ vertex_path, query, kind=kind_filter, since=since_ts, limit=100,
145
+ observer=observer,
146
+ )
147
+ else:
148
+ facts = vertex_facts(
149
+ vertex_path, since_ts, now.timestamp(), kind=kind_filter,
150
+ observer=observer,
151
+ )
152
+
153
+ # Key drill-down: filter facts by payload key field value
154
+ if key_filter is not None:
155
+ key_field = _get_key_field(vertex_path, kind_filter) if kind_filter else None
156
+ facts = [
157
+ f for f in facts
158
+ if _fact_matches_key(f, key_field, key_filter)
159
+ ]
160
+
161
+ facts.sort(key=lambda f: f["ts"], reverse=True)
162
+
163
+ # Normalize timestamps for JSON serialization
164
+ for f in facts:
165
+ if isinstance(f["ts"], datetime):
166
+ f["ts"] = f["ts"].isoformat()
167
+
168
+ # Get fold declarations for rendering hints
169
+ ast = parse_vertex_file(vertex_path)
170
+ fold_meta: dict[str, dict] = {}
171
+ for k, loop_def in ast.loops.items():
172
+ key_field = None
173
+ if loop_def.folds:
174
+ fold_decl = loop_def.folds[0]
175
+ if isinstance(fold_decl.op, FoldBy):
176
+ key_field = fold_decl.op.key_field
177
+ fold_meta[k] = {"key_field": key_field}
178
+
179
+ return {"facts": facts, "fold_meta": fold_meta, "vertex": ast.name}
180
+
181
+
182
+ def _fact_matches_key(fact: dict, key_field: str | None, key: str) -> bool:
183
+ """Check if a raw fact's payload matches a key value."""
184
+ payload = fact.get("payload", {})
185
+ candidates = [key_field] if key_field else []
186
+ candidates.extend(["topic", "name", "title", "summary"])
187
+
188
+ for field in candidates:
189
+ if field and field in payload:
190
+ val = str(payload[field])
191
+ if val.lower() == key.lower():
192
+ return True
193
+ return False
194
+
195
+
196
+ def fetch_fact_by_id(
197
+ vertex_path: Path,
198
+ fact_id: str,
199
+ ) -> dict | None:
200
+ """Fetch a single fact by ID or ID prefix.
201
+
202
+ Returns the full fact dict with id, kind, ts, observer, origin, payload.
203
+ Returns None if not found. Raises ValueError on ambiguous prefix.
204
+ """
205
+ from engine import vertex_fact_by_id
206
+
207
+ return vertex_fact_by_id(vertex_path, fact_id)
@@ -0,0 +1,222 @@
1
+ """Observer identity — who you are, workspace root, emit validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def _loops_home() -> Path:
10
+ """Resolve the loops config directory (delegates to main.loops_home)."""
11
+ from loops.main import loops_home
12
+
13
+ return loops_home()
14
+
15
+
16
+ def find_workspace_root(start: Path | None = None) -> Path | None:
17
+ """Find .vertex workspace root walking up from start (default: cwd).
18
+
19
+ 1. .loops/.vertex in start or ancestor dirs
20
+ 2. ~/.config/loops/.vertex (global)
21
+ Returns None if not found.
22
+ """
23
+ current = (start or Path.cwd()).resolve()
24
+ # Walk up from start
25
+ for d in [current, *current.parents]:
26
+ candidate = d / ".loops" / ".vertex"
27
+ if candidate.exists():
28
+ return candidate
29
+ # Global fallback
30
+ home = _loops_home()
31
+ global_root = home / ".vertex"
32
+ if global_root.exists():
33
+ return global_root
34
+ return None
35
+
36
+
37
+ def _read_observers(vertex_path: Path) -> tuple:
38
+ """Parse a .vertex file and return its observers tuple (or empty)."""
39
+ from lang import parse_vertex_file
40
+
41
+ try:
42
+ ast = parse_vertex_file(vertex_path)
43
+ return ast.observers or ()
44
+ except Exception:
45
+ return ()
46
+
47
+
48
+ def resolve_observer(explicit: str | None = None, start: Path | None = None) -> str:
49
+ """Resolve observer identity.
50
+
51
+ Priority chain:
52
+ 1. explicit (from --observer flag)
53
+ 2. LOOPS_OBSERVER env var
54
+ 3. Project .vertex observers block (walking up from start)
55
+ 4. Global .vertex observers block
56
+ Returns "" if nothing resolves.
57
+ """
58
+ # 1. Explicit flag
59
+ if explicit is not None:
60
+ return explicit
61
+
62
+ # 2. Env var
63
+ env = os.environ.get("LOOPS_OBSERVER")
64
+ if env is not None:
65
+ return env
66
+
67
+ # 3. Project-level .vertex (walk up)
68
+ current = (start or Path.cwd()).resolve()
69
+ for d in [current, *current.parents]:
70
+ candidate = d / ".loops" / ".vertex"
71
+ if candidate.exists():
72
+ observers = _read_observers(candidate)
73
+ if len(observers) == 1:
74
+ return observers[0].name
75
+ if observers:
76
+ # Multiple observers declared, can't auto-pick
77
+ return ""
78
+ break # found .vertex but no observers — fall through to global
79
+
80
+ # 4. Global .vertex
81
+ home = _loops_home()
82
+ global_path = home / ".vertex"
83
+ if global_path.exists():
84
+ observers = _read_observers(global_path)
85
+ if len(observers) == 1:
86
+ return observers[0].name
87
+
88
+ return ""
89
+
90
+
91
+ def _collect_combine_observers(vertex_path: Path) -> list:
92
+ """Collect observer declarations from combine/discover source vertices.
93
+
94
+ For aggregation vertices, follows the combine chain to source vertices
95
+ and collects their observer declarations. Same pattern as combine
96
+ auto-inherit for fold specs — the aggregation vertex accepts the
97
+ same observers as its sources.
98
+ """
99
+ from lang import parse_vertex_file, resolve_vertex
100
+
101
+ try:
102
+ ast = parse_vertex_file(vertex_path)
103
+ except Exception:
104
+ return []
105
+
106
+ observers: list = []
107
+ home = _loops_home()
108
+
109
+ if ast.combine is not None:
110
+ for entry in ast.combine:
111
+ vpath = resolve_vertex(entry.name, home)
112
+ if not vpath.is_absolute():
113
+ vpath = (vertex_path.parent / vpath).resolve()
114
+ if vpath.exists():
115
+ observers.extend(_read_observers(vpath))
116
+ # Also check the source vertex's workspace root
117
+ for d in [vpath.parent, *vpath.parent.parents]:
118
+ candidate = d / ".loops" / ".vertex"
119
+ if candidate.exists() and candidate.resolve() != vpath.resolve():
120
+ observers.extend(_read_observers(candidate))
121
+ break
122
+
123
+ elif ast.discover is not None:
124
+ base_dir = vertex_path.parent
125
+ for match in sorted(base_dir.glob(ast.discover)):
126
+ if match.suffix != ".vertex" or match.resolve() == vertex_path.resolve():
127
+ continue
128
+ observers.extend(_read_observers(match))
129
+
130
+ return observers
131
+
132
+
133
+ def validate_emit(vertex_path: Path, observer: str, kind: str) -> str | None:
134
+ """Validate observer + kind against vertex declaration chain.
135
+
136
+ Returns error message if rejected, None if allowed.
137
+ Checks:
138
+ 1. Observer is declared in the vertex itself, .vertex chain, or
139
+ combine/discover source vertices (cascade)
140
+ 2. Kind is in observer's grant.potential (if grant is declared)
141
+ """
142
+ if not observer:
143
+ return None # No observer to validate
144
+
145
+ # Check the vertex file itself first
146
+ vertex_observers = _read_observers(vertex_path)
147
+
148
+ # Collect observers from project-level .vertex
149
+ project_observers: tuple = ()
150
+ project_root = vertex_path.parent
151
+ for d in [project_root, *project_root.parents]:
152
+ candidate = d / ".loops" / ".vertex"
153
+ if candidate.exists() and candidate.resolve() != vertex_path.resolve():
154
+ project_observers = _read_observers(candidate)
155
+ break
156
+
157
+ # Collect observers from global .vertex
158
+ home = _loops_home()
159
+ global_observers: tuple = ()
160
+ global_path = home / ".vertex"
161
+ if global_path.exists() and global_path.resolve() != vertex_path.resolve():
162
+ global_observers = _read_observers(global_path)
163
+
164
+ # Collect observers from combine/discover source vertices (cascade)
165
+ combine_observers = _collect_combine_observers(vertex_path)
166
+
167
+ all_observers = (
168
+ list(vertex_observers)
169
+ + list(project_observers)
170
+ + list(global_observers)
171
+ + combine_observers
172
+ )
173
+
174
+ # No observers declared anywhere -> no validation (open system)
175
+ if not all_observers:
176
+ return None
177
+
178
+ # Find the observer declaration (supports namespaced observers)
179
+ from engine.observer import observer_matches
180
+
181
+ decl = None
182
+ for obs in all_observers:
183
+ if observer_matches(obs.name, observer):
184
+ decl = obs
185
+ break
186
+
187
+ if decl is None:
188
+ names = sorted({o.name for o in all_observers})
189
+ return f"Observer {observer!r} not declared. Known: {', '.join(names)}"
190
+
191
+ # Check grant.potential if declared
192
+ if decl.grant is not None and kind not in decl.grant.potential:
193
+ allowed = sorted(decl.grant.potential)
194
+ return f"Observer {observer!r} cannot emit kind {kind!r}. Allowed: {', '.join(allowed)}"
195
+
196
+ return None
197
+
198
+
199
+ def resolve_local_vertex(start: Path | None = None) -> Path:
200
+ """Find a vertex file via workspace root walk-up or session fallback.
201
+
202
+ Resolution order:
203
+ 1. Local vertex in cwd (*.vertex)
204
+ 2. LOOPS_HOME/session/session.vertex
205
+
206
+ Raises FileNotFoundError if neither found.
207
+ """
208
+ from loops.main import _find_local_vertex
209
+
210
+ # 1. Local vertex in cwd
211
+ local = _find_local_vertex()
212
+ if local is not None:
213
+ return local
214
+
215
+ # 2. LOOPS_HOME session fallback
216
+ session_vertex = _loops_home() / "session" / "session.vertex"
217
+ if session_vertex.exists():
218
+ return session_vertex
219
+
220
+ raise FileNotFoundError(
221
+ "No vertex found. Run 'loops init --template session' or 'loops emit <kind> ...' first."
222
+ )