witan-code 0.2.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.
witan_code/setup.py ADDED
@@ -0,0 +1,230 @@
1
+ """Witan-code's registration bundle, plus the omnigraph binary installer.
2
+
3
+ Per-agent MCP/skill/hook installation itself lives in ``agent_config_kit``
4
+ (``apply``/``apply_all``) — this module only builds witan-code's own
5
+ ``RegistrationBundle`` and keeps the omnigraph binary distribution logic.
6
+
7
+ The bundle-building shape is copied from witan/witan/setup.py so the two
8
+ Layer packages stay independent — no cross-package imports, matching
9
+ OmnigraphClient's own docstring in graph.py. ``_OMNIGRAPH_VERSION`` here is
10
+ kept in lockstep with witan's copy by the omnigraph-version customManager in
11
+ renovate.json — a single Renovate PR bumps both.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import platform
17
+ import re
18
+ import subprocess
19
+ import tarfile
20
+ import tempfile
21
+ import urllib.request
22
+ from pathlib import Path
23
+
24
+ from agent_config_kit import (
25
+ DeclarativeHook,
26
+ Hook,
27
+ HookEvent,
28
+ PluginRegistration,
29
+ RegistrationBundle,
30
+ SkillSource,
31
+ StdioServer,
32
+ )
33
+
34
+ _WITAN_CODE_ARGS = [
35
+ "--from",
36
+ "git+https://github.com/mitodl/agent-kit#subdirectory=mcp/servers/witan-code",
37
+ "witan-code",
38
+ "serve",
39
+ ]
40
+
41
+ _OMNIGRAPH_VERSION = "0.8.1"
42
+ _OMNIGRAPH_ASSETS: dict[tuple[str, str], str] = {
43
+ ("linux", "x86_64"): "omnigraph-linux-x86_64.tar.gz",
44
+ ("darwin", "arm64"): "omnigraph-macos-arm64.tar.gz",
45
+ }
46
+ _VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
47
+
48
+
49
+ def _installed_version(dest: Path) -> str | None:
50
+ """Return ``dest``'s reported version, or ``None`` if absent/unreadable.
51
+
52
+ A hung, corrupted, or non-executable binary must degrade to "unknown
53
+ version" (triggering a re-download) rather than crash `setup` —
54
+ ``subprocess.TimeoutExpired`` is a ``SubprocessError``, not an
55
+ ``OSError``, so both need catching, and a non-zero exit means the
56
+ output isn't trustworthy version text even if something printed.
57
+ """
58
+ if not dest.exists():
59
+ return None
60
+ try:
61
+ result = subprocess.run(
62
+ [str(dest), "--version"], capture_output=True, text=True, timeout=10
63
+ )
64
+ except (OSError, subprocess.SubprocessError):
65
+ return None
66
+ if result.returncode != 0:
67
+ return None
68
+ match = _VERSION_RE.search(result.stdout + result.stderr)
69
+ return match.group(0) if match else None
70
+
71
+
72
+ def witan_code_bundle(
73
+ pkg_dir: Path, author: str, *, binary: str = "witan-code"
74
+ ) -> RegistrationBundle:
75
+ """Build witan-code's ``RegistrationBundle``: MCP server, skill, hooks.
76
+
77
+ Independent of ``witan``'s own bundle (``witan.setup.witan_bundle``) — a
78
+ witan-code-only install (no witan) still gets the skill, hooks, and Pi
79
+ extension via standalone ``witan-code setup``. When both packages are
80
+ installed together and witan-code is importable, ``witan setup`` also
81
+ folds this bundle in automatically (see ``witan.cli.setup_cmd``), so a
82
+ single ``witan setup`` covers both; running ``witan-code setup``
83
+ separately afterwards is harmless (each `apply()` call is an idempotent
84
+ read-merge-write) but not required in that case.
85
+
86
+ Parameters
87
+ ----------
88
+ binary: The command name hook entries invoke — ``"witan-code"`` for a
89
+ standalone install (this function's default), or ``"witan code"``
90
+ when ``witan.cli.setup_cmd`` folds this bundle into witan's own (the
91
+ hooks then only need `witan` — with witan-code bundled in via
92
+ ``--with`` — on PATH, not a separately installed `witan-code`).
93
+ """
94
+ skills_dir = pkg_dir / "skills"
95
+ skills = (
96
+ [
97
+ SkillSource(name=d.name, skill_md_path=d / "SKILL.md")
98
+ for d in sorted(skills_dir.iterdir())
99
+ if (d / "SKILL.md").exists()
100
+ ]
101
+ if skills_dir.is_dir()
102
+ else []
103
+ )
104
+
105
+ pi_ext_dir = pkg_dir / "extensions" / "pi"
106
+ # Bare CLI commands, no wrapper script — portable everywhere `binary`
107
+ # installs (Windows included, where bash/setsid don't exist), matching
108
+ # witan's own `witan inject-context`/`session-checkpoint` hooks. The
109
+ # prompt-path timeouts mirror witan's: a hung git or store read must
110
+ # degrade to no context/no compaction, never stall the agent.
111
+ hooks: list[Hook] = [
112
+ DeclarativeHook(
113
+ event=HookEvent.SESSION_START,
114
+ command=f"{binary} session-init",
115
+ ),
116
+ DeclarativeHook(
117
+ event=HookEvent.POST_TOOL_USE,
118
+ matcher="Edit|Write",
119
+ command=f"{binary} reindex-hook",
120
+ ),
121
+ DeclarativeHook(
122
+ event=HookEvent.USER_PROMPT_SUBMIT,
123
+ command=f"{binary} inject-context",
124
+ timeout_seconds=15,
125
+ ),
126
+ DeclarativeHook(
127
+ event=HookEvent.STOP,
128
+ command=f"{binary} checkpoint",
129
+ timeout_seconds=15,
130
+ ),
131
+ ]
132
+ if pi_ext_dir.is_dir():
133
+ hooks.extend(
134
+ PluginRegistration(entry_path=f)
135
+ for f in sorted(pi_ext_dir.iterdir())
136
+ if f.suffix == ".ts"
137
+ )
138
+
139
+ return RegistrationBundle(
140
+ mcp_servers={
141
+ "witan-code": StdioServer(
142
+ command="uvx", args=_WITAN_CODE_ARGS, env={"WITAN_AUTHOR": author}
143
+ )
144
+ },
145
+ skills=skills,
146
+ hooks=hooks,
147
+ )
148
+
149
+
150
+ def install_omnigraph(dry_run: bool = False) -> None:
151
+ """Fetch the pinned omnigraph release into ``~/.local/bin/``.
152
+
153
+ Skips the download when a binary is already present and reports the
154
+ pinned version via ``--version``, so re-running always converges on the
155
+ current pin without refetching an already-correct binary.
156
+ """
157
+ local_bin = Path.home() / ".local" / "bin"
158
+ dest = local_bin / "omnigraph"
159
+ _download_omnigraph(dest, dry_run)
160
+
161
+
162
+ def _download_omnigraph(dest: Path, dry_run: bool) -> None:
163
+ from rich.console import Console
164
+
165
+ console = Console()
166
+
167
+ if _installed_version(dest) == _OMNIGRAPH_VERSION:
168
+ console.print(
169
+ f" [dim]omnigraph[/dim] — {dest} already at v{_OMNIGRAPH_VERSION}, skipping"
170
+ )
171
+ return
172
+
173
+ key = (platform.system().lower(), platform.machine().lower())
174
+ asset = _OMNIGRAPH_ASSETS.get(key)
175
+ if asset is None:
176
+ console.print(
177
+ f" [yellow]omnigraph[/yellow] — no pre-built binary for"
178
+ f" {key[0]}/{key[1]}; install manually"
179
+ )
180
+ return
181
+
182
+ url = (
183
+ f"https://github.com/ModernRelay/omnigraph/releases/download"
184
+ f"/v{_OMNIGRAPH_VERSION}/{asset}"
185
+ )
186
+ console.print(f" downloading omnigraph v{_OMNIGRAPH_VERSION} …")
187
+
188
+ if dry_run:
189
+ console.print(f" [green]omnigraph[/green] → {dest} [dim](dry-run)[/dim]")
190
+ return
191
+
192
+ dest.parent.mkdir(parents=True, exist_ok=True)
193
+ tmp_dest = dest.with_name(dest.name + ".tmp")
194
+ try:
195
+ extracted = False
196
+ with tempfile.TemporaryDirectory() as tmp:
197
+ archive = Path(tmp) / asset
198
+ try:
199
+ with (
200
+ urllib.request.urlopen(url, timeout=60) as resp,
201
+ open(archive, "wb") as fh,
202
+ ):
203
+ fh.write(resp.read())
204
+ except Exception as exc: # noqa: BLE001
205
+ console.print(
206
+ f" [red]omnigraph download failed[/red] ({exc}); install manually"
207
+ )
208
+ return
209
+ with tarfile.open(archive) as tf:
210
+ for member in tf.getmembers():
211
+ if member.name.split("/")[-1] == "omnigraph" and not member.isdir():
212
+ f = tf.extractfile(member)
213
+ if f:
214
+ tmp_dest.write_bytes(f.read())
215
+ extracted = True
216
+ break
217
+ if extracted:
218
+ tmp_dest.chmod(0o755)
219
+ tmp_dest.replace(dest)
220
+ console.print(f" [green]omnigraph[/green] → {dest}")
221
+ else:
222
+ console.print(
223
+ " [red]omnigraph[/red] — binary not found in archive; install manually"
224
+ )
225
+ except Exception as exc: # noqa: BLE001
226
+ console.print(
227
+ f" [red]omnigraph download failed[/red] ({exc}); install manually"
228
+ )
229
+ finally:
230
+ tmp_dest.unlink(missing_ok=True)
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: witan-code
3
+ description: >
4
+ Tree-sitter code graph for symbol lookups, caller graphs, change-impact
5
+ analysis, and cross-repo contract tracing. Use instead of grep/Explore when
6
+ you need exact symbol definitions, who-calls-what, blast radius before an
7
+ edit, or which repo provides/consumes a shared env var, endpoint, package, or
8
+ service. `/witan-code` reports whether the current repo is indexed and lists
9
+ the available `code_*` tools; `/witan-code reindex` forces a full rebuild.
10
+ Backed by the witan-code MCP server (code_* tools).
11
+ license: BSD-3-Clause
12
+ metadata:
13
+ category: workflow
14
+ ---
15
+
16
+ # Code Graph
17
+
18
+ A per-repo, tree-sitter-derived graph of symbols (functions, methods, classes,
19
+ modules) and their relationships (`Calls`, `References`, `Imports`,
20
+ `Inherits`), plus a cross-repo bridge linking services through shared
21
+ contracts (env vars, HTTP endpoints, packages, deployments). Indexing is
22
+ automatic — a `SessionStart` hook seeds/refreshes the whole repo in the
23
+ background, and a `PostToolUse` hook incrementally reindexes each file you
24
+ edit — so you rarely need to invoke the CLI yourself.
25
+
26
+ ## When to use this vs. grep / the `Explore` agent
27
+
28
+ Reach for `code_*` tools when the question is **structural**, not textual:
29
+
30
+ - "Where is `Service.run` defined?" → `code_find_definition` / `code_search_symbol`
31
+ - "What calls this function, anywhere in the repo?" → `code_callers` / `code_find_references`
32
+ - "If I change this, what breaks?" (transitive blast radius) → `code_impact`
33
+ - "What symbols does this file define?" → `code_symbols_in_file`
34
+ - "Which other repo reads this env var / calls this endpoint / imports this package?" → `code_interface_consumers` / `code_interface_providers` / `code_cross_repo_impact`
35
+
36
+ Grep is still the right tool for literal string/comment searches, one-off text
37
+ matches, or a repo with no code graph yet. The two are complementary: grep
38
+ finds text; `code_*` tools resolve **symbols** and their **relationships**,
39
+ which grep cannot do (it can't tell you a function's transitive callers or
40
+ which service consumes an endpoint another service serves).
41
+
42
+ **Caveat:** `Calls`/`References`/`Imports`/`Inherits` edges are heuristic
43
+ (syntactic name resolution, not a true call graph) — treat impact/caller
44
+ results as a high-recall starting point, not a verified answer. `Defines` and
45
+ `Contains` are exact.
46
+
47
+ ## On invocation
48
+
49
+ - No args → **Check readiness**, then show the tool reference below.
50
+ - `reindex` → call the `code_reindex` MCP tool (or `witan-code reindex` if
51
+ running the CLI directly) to force a full rebuild of the current repo,
52
+ ignoring content hashes. Use when the graph looks stale and you don't want
53
+ to wait for the incremental hooks to catch up.
54
+
55
+ ## Check readiness
56
+
57
+ Before relying on results, confirm the current repo has a graph: call
58
+ `code_symbols_in_file` on a file you know exists, or `code_search_symbol` with
59
+ a term you expect to match. An empty result on a file/symbol you know exists
60
+ usually means indexing hasn't finished yet (the `SessionStart` hook runs
61
+ detached in the background on a fresh repo) — wait a few seconds and retry
62
+ before concluding the graph is broken.
63
+
64
+ ## Tool reference
65
+
66
+ | Question | Tool |
67
+ |---|---|
68
+ | Find a symbol by name | `code_find_definition(name)` / `code_search_symbol(query)` (BM25) |
69
+ | What's in this file? | `code_symbols_in_file(path)` |
70
+ | Who references/calls this symbol? | `code_find_references(symbol_id)` (References+Calls) / `code_callers(symbol_id)` (Calls only) |
71
+ | Blast radius of a change | `code_impact(symbol_id, max_depth=5, max_nodes=200)` — transitive callers via BFS |
72
+ | Force a reindex | `code_reindex(path=None)` |
73
+
74
+ Cross-repo (reads the shared bridge store; `kind` is one of `env_var` /
75
+ `endpoint` / `package` / `service`):
76
+
77
+ | Question | Tool |
78
+ |---|---|
79
+ | Who provides this contract? | `code_interface_providers(kind, key)` |
80
+ | Who consumes it? | `code_interface_consumers(kind, key)` |
81
+ | Every binding for this symbol's contracts, across repos | `code_cross_repo_impact(symbol_id)` |
82
+ | Search bindings by key | `code_interface_search(query, kind=None)` |
83
+
84
+ Every tool resolves the per-repo store from the current working directory and
85
+ returns `[]`/`null` gracefully when nothing is indexed yet — an empty result
86
+ is not necessarily an error, see **Check readiness** above.
87
+
88
+ ## Linking to witan (tasks and memories)
89
+
90
+ `code_find_definition` / `code_search_symbol` return a `symbol_id` of the form
91
+ `<repo>#<path/to/file.py>::<QualifiedName>`. Attach it to a witan task
92
+ (`task_create(..., symbol_refs=[...])`, see `/witan-task`) or memory
93
+ (`memory_store(..., symbol_refs=[...])`, see `/witan-memory`) so the work or
94
+ lesson is discoverable from the code later. Going the other way,
95
+ `symbol_context(symbol_id)` (a witan tool) lists the tasks and memories
96
+ already attached to a symbol — call it before editing code that might have
97
+ open work or known gotchas attached.
witan_code/stitch.py ADDED
@@ -0,0 +1,177 @@
1
+ """Stage 2 — cross-repo symbol stitching at read time (docs/SYMBOL_TABLE.md).
2
+
3
+ Joins ``RepoSymbol`` rows across the whole bridge store to compute precise
4
+ cross-repo edges WITHOUT ever writing them: each repo's ``external``
5
+ (unresolved reference) rows are matched against every OTHER repo's
6
+ ``exported`` rows by canonical symbol identity. This is the read-time join
7
+ Stage 1 exists to serve — the RANGER/SCIP pattern of deferring cross-repo
8
+ linking to query time instead of guessing at extraction time.
9
+
10
+ Join keys (docs/SYMBOL_TABLE.md § Stage-2 join contract):
11
+ * ``env`` / ``svc`` — exact ``(scheme, descriptor)``.
12
+ * ``http`` / ``pkg`` — ``(scheme, key_norm)``; ``http`` additionally
13
+ requires method compatibility (a consumer method of ``*`` matches any
14
+ provider method) since key_norm collapses the method-bearing descriptor
15
+ down to the bare path. ``pkg`` descriptors are always ``.``
16
+ (SYMBOL_FORMAT.md), so key_norm — which carries the package name for
17
+ both roles — is the only usable join key for packages.
18
+
19
+ Version disambiguation follows SYMBOL_FORMAT.md decision 1: prefer an exact
20
+ version match, then ``main``, else flag the group ``ambiguous_version``
21
+ rather than guessing a winner. Every candidate is still returned as its own
22
+ edge (this project's consistent pattern of surfacing all cross-repo data and
23
+ letting callers filter — see ``code_cross_repo_impact``); ``preferred`` marks
24
+ which candidate(s) survive the version-preference rule and ``match_count``
25
+ lets a caller tell a clean single match from a fan-out before deciding
26
+ whether to trust it.
27
+
28
+ Existing URL-heuristic extraction (``visualize.cross_repo_edges``, keyed on
29
+ the coarser ``(kind, key_norm)`` binding grouping) is the Stage-3 fallback
30
+ tier for external symbols that fail to join here — see
31
+ ``get_unresolved_symbols`` below.
32
+ """
33
+
34
+ from dataclasses import dataclass
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PreciseEdge:
39
+ consumer_repo: str
40
+ consumer_symbol: str
41
+ provider_repo: str
42
+ provider_symbol: str
43
+ kind: str
44
+ key_norm: str
45
+ scheme: str
46
+ match_count: int
47
+ preferred: bool
48
+ ambiguous_version: bool
49
+ # One Stage-1 exemplar occurrence per side (RepoSymbol keeps only one, not
50
+ # every occurrence — see SYMBOL_TABLE.md); omitted where the store row
51
+ # predates file/line or has none. Consumed by witan_code.edges to build
52
+ # the typed-edge evidence list for the :CALLS/precise tier.
53
+ evidence: tuple[dict, ...] = ()
54
+
55
+ def as_dict(self) -> dict:
56
+ return {
57
+ "consumer_repo": self.consumer_repo,
58
+ "consumer_symbol": self.consumer_symbol,
59
+ "provider_repo": self.provider_repo,
60
+ "provider_symbol": self.provider_symbol,
61
+ "kind": self.kind,
62
+ "key_norm": self.key_norm,
63
+ "scheme": self.scheme,
64
+ "match_count": self.match_count,
65
+ "preferred": self.preferred,
66
+ "ambiguous_version": self.ambiguous_version,
67
+ "evidence": list(self.evidence),
68
+ }
69
+
70
+
71
+ def _method(descriptor: str | None) -> str | None:
72
+ """The leading method token of an http descriptor, or None if there isn't one."""
73
+ if not descriptor:
74
+ return None
75
+ method, _, rest = descriptor.partition(" ")
76
+ return method if rest else None
77
+
78
+
79
+ def _http_compatible(
80
+ consumer_descriptor: str | None, provider_descriptor: str | None
81
+ ) -> bool:
82
+ consumer_method = _method(consumer_descriptor)
83
+ return consumer_method in (None, "*") or consumer_method == _method(
84
+ provider_descriptor
85
+ )
86
+
87
+
88
+ def _join_key(row: dict) -> tuple:
89
+ scheme = row["scheme"]
90
+ if scheme in ("env", "svc"):
91
+ return (scheme, "descriptor", row["descriptor"])
92
+ return (scheme, "key_norm", row["key_norm"])
93
+
94
+
95
+ def _select_preferred(
96
+ consumer_version: str | None, candidates: list[dict]
97
+ ) -> tuple[set, bool]:
98
+ """Indices of the preferred candidate(s) plus whether the group is ambiguous.
99
+
100
+ SYMBOL_FORMAT.md decision 1: prefer an exact version match, then ``main``,
101
+ else every remaining candidate is preferred and the group is ambiguous
102
+ (more than one — don't guess a winner).
103
+ """
104
+
105
+ def where(pred) -> set:
106
+ return {i for i, c in enumerate(candidates) if pred(c)}
107
+
108
+ if consumer_version not in (".", "", None):
109
+ exact = where(lambda c: c["version"] == consumer_version)
110
+ if exact:
111
+ return exact, len(exact) > 1
112
+ main = where(lambda c: c["version"] == "main")
113
+ if main:
114
+ return main, len(main) > 1
115
+ return set(range(len(candidates))), len(candidates) > 1
116
+
117
+
118
+ def _evidence(ext: dict, prov: dict) -> tuple[dict, ...]:
119
+ out = []
120
+ for row in (ext, prov):
121
+ if row.get("file"):
122
+ out.append(
123
+ {"repo": row["repo"], "file": row["file"], "line": row.get("line")}
124
+ )
125
+ return tuple(out)
126
+
127
+
128
+ def resolve(rows: list[dict]) -> tuple[list[PreciseEdge], list[dict]]:
129
+ """Join a full ``RepoSymbol`` dump (``all_repo_symbols``) into edges + gaps.
130
+
131
+ Returns ``(precise_edges, unresolved)``: every external row that matched
132
+ at least one exported row from another repo becomes one ``PreciseEdge``
133
+ per candidate; external rows with zero candidates are returned verbatim
134
+ in ``unresolved`` (the raw ``RepoSymbol`` dict) for
135
+ ``get_unresolved_symbols`` / Stage-3 fallback.
136
+ """
137
+ exported: dict[tuple, list[dict]] = {}
138
+ external: list[dict] = []
139
+ for row in rows:
140
+ if row["role"] == "exported":
141
+ exported.setdefault(_join_key(row), []).append(row)
142
+ else:
143
+ external.append(row)
144
+
145
+ edges: list[PreciseEdge] = []
146
+ unresolved: list[dict] = []
147
+ for ext in external:
148
+ candidates = [
149
+ prov
150
+ for prov in exported.get(_join_key(ext), [])
151
+ if prov["repo"] != ext["repo"]
152
+ and (
153
+ ext["scheme"] != "http"
154
+ or _http_compatible(ext["descriptor"], prov["descriptor"])
155
+ )
156
+ ]
157
+ if not candidates:
158
+ unresolved.append(ext)
159
+ continue
160
+ preferred_idx, ambiguous = _select_preferred(ext["version"], candidates)
161
+ for i, prov in enumerate(candidates):
162
+ edges.append(
163
+ PreciseEdge(
164
+ consumer_repo=ext["repo"],
165
+ consumer_symbol=ext["symbol"],
166
+ provider_repo=prov["repo"],
167
+ provider_symbol=prov["symbol"],
168
+ kind=ext["kind"],
169
+ key_norm=ext.get("key_norm") or prov.get("key_norm") or "",
170
+ scheme=ext["scheme"],
171
+ match_count=len(candidates),
172
+ preferred=i in preferred_idx,
173
+ ambiguous_version=ambiguous,
174
+ evidence=_evidence(ext, prov),
175
+ )
176
+ )
177
+ return edges, unresolved
witan_code/store.py ADDED
@@ -0,0 +1,116 @@
1
+ """Per-repo store resolution and lazy initialisation."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from . import config as cfg_module
7
+ from .graph import OmnigraphClient
8
+
9
+
10
+ def store_for_repo(slug: str, config: cfg_module.Config | None = None) -> Path:
11
+ """Return the store path for ``slug`` without creating it."""
12
+ cfg = config or cfg_module.load()
13
+ return cfg_module.store_path(slug, cfg.code_dir)
14
+
15
+
16
+ def store_exists(slug: str, config: cfg_module.Config | None = None) -> bool:
17
+ return store_for_repo(slug, config).exists()
18
+
19
+
20
+ def ensure_store(slug: str, config: cfg_module.Config | None = None) -> Path:
21
+ """Resolve the per-repo store, initialising it from the schema if missing.
22
+
23
+ Mirrors install.sh: ``omnigraph init --schema <schema> <store>``. The flag
24
+ style is copied from the memory install script (the only place a per-store
25
+ init is evidenced); see ASSUMPTION note below.
26
+ """
27
+ cfg = config or cfg_module.load()
28
+ store = cfg_module.store_path(slug, cfg.code_dir)
29
+ binary = _binary()
30
+
31
+ if not store.exists():
32
+ store.parent.mkdir(parents=True, exist_ok=True)
33
+ # ASSUMPTION: `omnigraph init --schema <file> <store>` is the per-store
34
+ # init form, matching witan/install.sh.
35
+ subprocess.run(
36
+ [binary, "init", "--schema", str(cfg.schema_file), str(store)],
37
+ check=True,
38
+ capture_output=True,
39
+ text=True,
40
+ )
41
+ # Force apply on first creation — stamp will be written below.
42
+ _schema_apply(binary, cfg.schema_file, store)
43
+ else:
44
+ # Only re-apply when the schema file has changed since the last apply.
45
+ # schema apply is additive/idempotent but spawns a subprocess on every
46
+ # PostToolUse reindex; the mtime sidecar avoids the cost on hot paths.
47
+ _schema_apply_if_changed(binary, cfg.schema_file, store)
48
+
49
+ # Record the canonical repo URI in a sidecar so listings can show it even for
50
+ # a 0-file store (sanitize_slug is lossy — its `_` collapse isn't reversible).
51
+ repo_sidecar(store).write_text(slug)
52
+ return store
53
+
54
+
55
+ def repo_sidecar(store: Path) -> Path:
56
+ """Sidecar file next to a store holding its canonical repo URI."""
57
+ return store.parent / f"{store.name}.repo"
58
+
59
+
60
+ def _schema_stamp(store: Path) -> Path:
61
+ return store.parent / f"{store.name}.schema_mtime"
62
+
63
+
64
+ def _schema_apply(binary: str, schema_file: Path, store: Path) -> None:
65
+ res = subprocess.run(
66
+ [binary, "schema", "apply", "--schema", str(schema_file), str(store)],
67
+ capture_output=True,
68
+ text=True,
69
+ )
70
+ if res.returncode == 0:
71
+ _schema_stamp(store).write_text(str(schema_file.stat().st_mtime))
72
+
73
+
74
+ def _schema_apply_if_changed(binary: str, schema_file: Path, store: Path) -> None:
75
+ stamp = _schema_stamp(store)
76
+ current_mtime = str(schema_file.stat().st_mtime)
77
+ if stamp.exists() and stamp.read_text().strip() == current_mtime:
78
+ return
79
+ _schema_apply(binary, schema_file, store)
80
+
81
+
82
+ def bridge_store(config: cfg_module.Config | None = None) -> Path:
83
+ """Return the shared bridge store path without creating it."""
84
+ cfg = config or cfg_module.load()
85
+ return cfg_module.bridge_store_path(cfg.code_dir)
86
+
87
+
88
+ def ensure_bridge_store(config: cfg_module.Config | None = None) -> Path:
89
+ """Resolve the shared bridge store, initialising it from bridge-schema.pg.
90
+
91
+ Mirrors ``ensure_store`` but uses the bridge schema and the fixed
92
+ ``_bridge.omni`` filename. ``schema apply`` builds the FTS index on
93
+ ``key_norm`` that ``search_bindings`` needs.
94
+ """
95
+ cfg = config or cfg_module.load()
96
+ store = cfg_module.bridge_store_path(cfg.code_dir)
97
+ binary = _binary()
98
+ if store.exists():
99
+ # Pick up additive schema changes (new nodes/fields) on existing
100
+ # stores; the mtime stamp keeps hot reindex paths subprocess-free.
101
+ _schema_apply_if_changed(binary, cfg.bridge_schema_file, store)
102
+ return store
103
+
104
+ store.parent.mkdir(parents=True, exist_ok=True)
105
+ subprocess.run(
106
+ [binary, "init", "--schema", str(cfg.bridge_schema_file), str(store)],
107
+ check=True,
108
+ capture_output=True,
109
+ text=True,
110
+ )
111
+ _schema_apply(binary, cfg.bridge_schema_file, store)
112
+ return store
113
+
114
+
115
+ def _binary() -> str:
116
+ return OmnigraphClient._find_binary()