severino-vault-engine 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 (34) hide show
  1. severino_vault_engine-0.1.0/.gitignore +5 -0
  2. severino_vault_engine-0.1.0/PKG-INFO +70 -0
  3. severino_vault_engine-0.1.0/README.md +45 -0
  4. severino_vault_engine-0.1.0/pyproject.toml +66 -0
  5. severino_vault_engine-0.1.0/src/vault_engine/__init__.py +11 -0
  6. severino_vault_engine-0.1.0/src/vault_engine/atomic_write.py +119 -0
  7. severino_vault_engine-0.1.0/src/vault_engine/brief_service.py +98 -0
  8. severino_vault_engine-0.1.0/src/vault_engine/cli_introspect.py +45 -0
  9. severino_vault_engine-0.1.0/src/vault_engine/config.py +202 -0
  10. severino_vault_engine-0.1.0/src/vault_engine/context.py +37 -0
  11. severino_vault_engine-0.1.0/src/vault_engine/core_tools.py +954 -0
  12. severino_vault_engine-0.1.0/src/vault_engine/daily_notes.py +167 -0
  13. severino_vault_engine-0.1.0/src/vault_engine/daily_write.py +83 -0
  14. severino_vault_engine-0.1.0/src/vault_engine/doctor.py +211 -0
  15. severino_vault_engine-0.1.0/src/vault_engine/frontmatter.py +217 -0
  16. severino_vault_engine-0.1.0/src/vault_engine/jsonio.py +65 -0
  17. severino_vault_engine-0.1.0/src/vault_engine/mirror.py +74 -0
  18. severino_vault_engine-0.1.0/src/vault_engine/paths.py +82 -0
  19. severino_vault_engine-0.1.0/src/vault_engine/schema.py +196 -0
  20. severino_vault_engine-0.1.0/src/vault_engine/search.py +143 -0
  21. severino_vault_engine-0.1.0/src/vault_engine/secret_unlock.py +165 -0
  22. severino_vault_engine-0.1.0/src/vault_engine/sections.py +222 -0
  23. severino_vault_engine-0.1.0/src/vault_engine/sensitivity.py +97 -0
  24. severino_vault_engine-0.1.0/src/vault_engine/tabular.py +73 -0
  25. severino_vault_engine-0.1.0/src/vault_engine/task_service.py +474 -0
  26. severino_vault_engine-0.1.0/src/vault_engine/vault.py +233 -0
  27. severino_vault_engine-0.1.0/src/vault_engine/vault_query_service.py +222 -0
  28. severino_vault_engine-0.1.0/src/vault_engine/vault_search_service.py +141 -0
  29. severino_vault_engine-0.1.0/src/vault_engine/vault_write_service.py +382 -0
  30. severino_vault_engine-0.1.0/tests/test_education_profile.py +63 -0
  31. severino_vault_engine-0.1.0/tests/test_jsonio.py +46 -0
  32. severino_vault_engine-0.1.0/tests/test_schema.py +69 -0
  33. severino_vault_engine-0.1.0/tests/test_tabular.py +45 -0
  34. severino_vault_engine-0.1.0/tests/test_task_service.py +210 -0
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .uv/
5
+ uv.lock
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: severino-vault-engine
3
+ Version: 0.1.0
4
+ Summary: Domain-agnostic vault-governance engine: frontmatter schema profiles, indexing, ranked search, a task ledger, atomic writes, and a composable MCP core (register_core). The reusable core behind severino-vault-mcp and severino-edu-mcp.
5
+ Project-URL: Homepage, https://github.com/joeseverino/vault-engine
6
+ Project-URL: Issues, https://github.com/joeseverino/vault-engine/issues
7
+ Author-email: Joseph Severino <github@jseverino.com>
8
+ License-Expression: MIT
9
+ Keywords: frontmatter,local-first,mcp,model-context-protocol,obsidian,vault
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: cordon-emit
19
+ Requires-Dist: mcp>=1.27
20
+ Requires-Dist: starlette>=1.0.1
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # vault-engine
27
+
28
+ A domain-agnostic **vault-governance engine** — the reusable core behind
29
+ [`severino-vault-mcp`](https://github.com/joeseverino/severino-vault-mcp) and
30
+ `severino-edu-mcp`. It governs a Git-backed, frontmatter-tagged Markdown vault
31
+ (Obsidian-style) and exposes that governance both as a library and as a
32
+ composable MCP tool surface.
33
+
34
+ ## What's in it
35
+
36
+ - **`SchemaProfile`** — a vault's frontmatter contract (doc-types, statuses,
37
+ id-prefixes, the task lifecycle), with `as_dict()` (HQ/CI contract) and
38
+ `check_doc_enums()`. One engine, many profiles: a different vault is a
39
+ different profile, not a fork.
40
+ - **Index + search** — a lenient frontmatter index and ranked, section-scoped
41
+ retrieval (`find_sections`) that returns menus, never raw bodies.
42
+ - **Task ledger** — `doc_type: task` docs derived from the index, with a
43
+ validated write path.
44
+ - **Atomic writes** — a single serializer and `atomic_write_text` /
45
+ `transactional_replace`, so every writer shares one escaping + durability rule.
46
+ - **`register_core(mcp, ctx)`** — composes the generic MCP tools (search, doc
47
+ read with a sensitivity gate, project inventory, daily progress, the task
48
+ ledger, schema-validated frontmatter writes) onto any FastMCP server.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install vault-engine # or: uv add vault-engine
54
+ ```
55
+
56
+ ## Use
57
+
58
+ ```python
59
+ from vault_engine.config import Config
60
+ from vault_engine.context import ServerContext
61
+ from vault_engine.core_tools import register_core
62
+ from mcp.server.fastmcp import FastMCP
63
+
64
+ ctx = ServerContext(Config.from_env()) # your vault + profile
65
+ mcp = FastMCP("my-vault-mcp")
66
+ register_core(mcp, ctx) # + your own tool groups
67
+ ```
68
+
69
+ The servers in the family own only their domain (writeup/topology tools, course
70
+ tools) and a thin entrypoint; everything generic lives here.
@@ -0,0 +1,45 @@
1
+ # vault-engine
2
+
3
+ A domain-agnostic **vault-governance engine** — the reusable core behind
4
+ [`severino-vault-mcp`](https://github.com/joeseverino/severino-vault-mcp) and
5
+ `severino-edu-mcp`. It governs a Git-backed, frontmatter-tagged Markdown vault
6
+ (Obsidian-style) and exposes that governance both as a library and as a
7
+ composable MCP tool surface.
8
+
9
+ ## What's in it
10
+
11
+ - **`SchemaProfile`** — a vault's frontmatter contract (doc-types, statuses,
12
+ id-prefixes, the task lifecycle), with `as_dict()` (HQ/CI contract) and
13
+ `check_doc_enums()`. One engine, many profiles: a different vault is a
14
+ different profile, not a fork.
15
+ - **Index + search** — a lenient frontmatter index and ranked, section-scoped
16
+ retrieval (`find_sections`) that returns menus, never raw bodies.
17
+ - **Task ledger** — `doc_type: task` docs derived from the index, with a
18
+ validated write path.
19
+ - **Atomic writes** — a single serializer and `atomic_write_text` /
20
+ `transactional_replace`, so every writer shares one escaping + durability rule.
21
+ - **`register_core(mcp, ctx)`** — composes the generic MCP tools (search, doc
22
+ read with a sensitivity gate, project inventory, daily progress, the task
23
+ ledger, schema-validated frontmatter writes) onto any FastMCP server.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install vault-engine # or: uv add vault-engine
29
+ ```
30
+
31
+ ## Use
32
+
33
+ ```python
34
+ from vault_engine.config import Config
35
+ from vault_engine.context import ServerContext
36
+ from vault_engine.core_tools import register_core
37
+ from mcp.server.fastmcp import FastMCP
38
+
39
+ ctx = ServerContext(Config.from_env()) # your vault + profile
40
+ mcp = FastMCP("my-vault-mcp")
41
+ register_core(mcp, ctx) # + your own tool groups
42
+ ```
43
+
44
+ The servers in the family own only their domain (writeup/topology tools, course
45
+ tools) and a thin entrypoint; everything generic lives here.
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "severino-vault-engine"
7
+ version = "0.1.0"
8
+ description = "Domain-agnostic vault-governance engine: frontmatter schema profiles, indexing, ranked search, a task ledger, atomic writes, and a composable MCP core (register_core). The reusable core behind severino-vault-mcp and severino-edu-mcp."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Joseph Severino", email = "github@jseverino.com" }]
13
+ keywords = [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "obsidian",
17
+ "frontmatter",
18
+ "local-first",
19
+ "vault",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Software Development :: Libraries",
29
+ ]
30
+ dependencies = [
31
+ "mcp>=1.27",
32
+ "starlette>=1.0.1",
33
+ # cordon's Python reference emitter — the single source for the command-surface
34
+ # contract (cli_introspect.py is a thin binding). Resolved from cordon's main
35
+ # via the git source below.
36
+ "cordon-emit",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8.0",
42
+ "ruff>=0.6",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/joeseverino/vault-engine"
47
+ Issues = "https://github.com/joeseverino/vault-engine/issues"
48
+
49
+ [tool.uv.sources]
50
+ cordon-emit = { git = "https://github.com/joeseverino/cordon", subdirectory = "emitters/python" }
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/vault_engine"]
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+ target-version = "py311"
58
+
59
+ [tool.ruff.lint]
60
+ select = ["E", "F", "W", "I", "B", "UP", "SIM"]
61
+ ignore = ["E501"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ addopts = "-ra --strict-markers"
66
+ pythonpath = ["src"]
@@ -0,0 +1,11 @@
1
+ """vault-engine — a domain-agnostic vault-governance engine.
2
+
3
+ The reusable core extracted from severino-vault-mcp: a frontmatter
4
+ ``SchemaProfile`` framework, a lenient vault index, ranked section search, a
5
+ task ledger, atomic/transactional writes, a sensitivity gate, and a composable
6
+ MCP tool surface (``register_core``). Servers (severino-vault-mcp,
7
+ severino-edu-mcp) compose this engine against their own vault + profile; the
8
+ engine itself carries no server or domain knowledge.
9
+ """
10
+
11
+ __version__ = "0.1.0"
@@ -0,0 +1,119 @@
1
+ """Durable file replacement primitives shared by every vault writer.
2
+
3
+ Both the single-file generic frontmatter writers and the multi-file writeup
4
+ transaction need the same staged-tempfile + ``fsync`` + ``os.replace`` dance to
5
+ avoid truncating a doc on a failed write. Centralizing it here means there is
6
+ one implementation of "replace a file durably," and the transactional path is
7
+ just the locked, multi-file generalization of :func:`atomic_write_text`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fcntl
13
+ import hashlib
14
+ import os
15
+ import tempfile
16
+ from contextlib import suppress
17
+ from pathlib import Path
18
+
19
+
20
+ def _stage_sibling(path: Path, data: bytes, *, prefix: str) -> Path:
21
+ """Write ``data`` to a flushed, fsynced sibling temp file and return it.
22
+
23
+ Staging in the target's own directory keeps the later ``os.replace`` atomic
24
+ (same filesystem) and leaves the original intact until the rename succeeds.
25
+ """
26
+ with tempfile.NamedTemporaryFile(
27
+ mode="wb",
28
+ dir=path.parent,
29
+ prefix=prefix,
30
+ delete=False,
31
+ ) as handle:
32
+ handle.write(data)
33
+ handle.flush()
34
+ os.fsync(handle.fileno())
35
+ return Path(handle.name)
36
+
37
+
38
+ def atomic_write_text(path: Path, text: str) -> None:
39
+ """Durably replace one text file through a sibling temporary file."""
40
+ staged: Path | None = None
41
+ try:
42
+ staged = _stage_sibling(
43
+ path,
44
+ text.encode("utf-8"),
45
+ prefix=f".{path.name}.svmc-",
46
+ )
47
+ os.replace(staged, path)
48
+ staged = None
49
+ finally:
50
+ if staged is not None:
51
+ with suppress(FileNotFoundError):
52
+ staged.unlink()
53
+
54
+
55
+ def transactional_replace(
56
+ root: Path,
57
+ replacements: dict[Path, str],
58
+ ) -> tuple[bool, str | None]:
59
+ """Stage all replacements, then replace under a lock with rollback.
60
+
61
+ Every target is staged first; under an exclusive lock keyed to ``root`` the
62
+ files are checked for concurrent modification and then replaced. Any failure
63
+ rolls back the files already swapped, so the set lands all-or-nothing.
64
+ """
65
+ if not replacements:
66
+ return True, None
67
+
68
+ originals = {path: path.read_bytes() for path in replacements}
69
+ staged: dict[Path, Path] = {}
70
+ replaced: list[Path] = []
71
+ lock_key = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:16]
72
+ lock_path = Path(tempfile.gettempdir()) / f"svmc-writeups-{lock_key}.lock"
73
+ try:
74
+ for path, text in replacements.items():
75
+ staged[path] = _stage_sibling(
76
+ path,
77
+ text.encode("utf-8"),
78
+ prefix=f".{path.name}.svmc-",
79
+ )
80
+
81
+ with lock_path.open("a+b") as lock_handle:
82
+ fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX)
83
+ try:
84
+ for path, original in originals.items():
85
+ if path.read_bytes() != original:
86
+ raise RuntimeError(
87
+ f"file changed during transaction: {path}"
88
+ )
89
+ for path in sorted(replacements, key=str):
90
+ os.replace(staged[path], path)
91
+ replaced.append(path)
92
+ except (OSError, RuntimeError) as exc:
93
+ rollback_errors: list[str] = []
94
+ for path in reversed(replaced):
95
+ try:
96
+ rollback_path = _stage_sibling(
97
+ path,
98
+ originals[path],
99
+ prefix=f".{path.name}.rollback-",
100
+ )
101
+ os.replace(rollback_path, path)
102
+ except OSError as rollback_exc:
103
+ rollback_errors.append(f"{path}: {rollback_exc}")
104
+ detail = str(exc)
105
+ if rollback_errors:
106
+ detail += "; rollback errors: " + "; ".join(rollback_errors)
107
+ return False, detail
108
+ finally:
109
+ fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
110
+ return True, None
111
+ except OSError as exc:
112
+ return False, str(exc)
113
+ finally:
114
+ for path in staged.values():
115
+ with suppress(FileNotFoundError):
116
+ path.unlink()
117
+
118
+
119
+ __all__ = ["atomic_write_text", "transactional_replace"]
@@ -0,0 +1,98 @@
1
+ """Vault brief: the deterministic "state of the vault" aggregate.
2
+
3
+ One emit-once source for the doc-side vault facts an agent otherwise re-derives
4
+ every session — recent changes, docs overdue for review, and inbox backlog —
5
+ so the `brief` shell tool can compose them with repo and writeup state in a
6
+ single cheap read instead of inferring from raw files.
7
+
8
+ FastMCP-free (the service spine): the same code backs both the MCP and the CLI.
9
+ Writeup state keeps its own owner (`writeup_service` / `list-writeups`); this
10
+ stays doc-focused so each fact has exactly one owner.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import date
16
+ from typing import Any
17
+
18
+ from .task_service import list_tasks
19
+ from .vault import VaultLoader
20
+ from .vault_query_service import recent_changes
21
+
22
+
23
+ def _age_days(iso: str | None) -> int | None:
24
+ """Days since an ISO `last_reviewed` date, or None if absent/unparseable."""
25
+ if not iso:
26
+ return None
27
+ try:
28
+ year, month, day = (int(part) for part in iso[:10].split("-"))
29
+ return (date.today() - date(year, month, day)).days
30
+ except (ValueError, IndexError):
31
+ return None
32
+
33
+
34
+ def vault_brief(
35
+ loader: VaultLoader,
36
+ *,
37
+ days: int = 7,
38
+ review_after_days: int = 180,
39
+ recent_limit: int = 15,
40
+ ) -> dict[str, Any]:
41
+ """Doc-side vault state in one structured payload.
42
+
43
+ - `recent_changes`: vault commits in the indexed dirs over the last `days`.
44
+ - `docs_to_review`: indexed docs whose `last_reviewed` is older than
45
+ `review_after_days`, newest-stale first.
46
+ - `inbox`: count of top-level `00 Inbox/*.md` captures.
47
+ """
48
+ review_after_days = max(0, int(review_after_days))
49
+ idx = loader.index()
50
+
51
+ changes = recent_changes(loader, days, recent_limit)
52
+ commits = changes.get("commits", []) if isinstance(changes, dict) else []
53
+ changes_error = changes.get("error") if isinstance(changes, dict) else None
54
+
55
+ stale: list[dict[str, Any]] = []
56
+ for doc in idx.docs:
57
+ age = _age_days(doc.last_reviewed)
58
+ if age is not None and age > review_after_days:
59
+ stale.append(
60
+ {
61
+ "doc_id": doc.doc_id,
62
+ "title": doc.title,
63
+ "obsidian_path": doc.relative_path,
64
+ "last_reviewed": doc.last_reviewed,
65
+ "age_days": age,
66
+ }
67
+ )
68
+ stale.sort(key=lambda entry: entry["age_days"], reverse=True)
69
+
70
+ inbox_dir = loader.config.vault_path / "00 Inbox"
71
+ inbox_count = sum(1 for _ in inbox_dir.glob("*.md")) if inbox_dir.is_dir() else 0
72
+
73
+ # Tasks are doc-side vault data; the board's owner (list_tasks) summarizes
74
+ # them so the brief surfaces open work + stale debt without re-deriving.
75
+ board = list_tasks(loader)
76
+ tasks_summary = {
77
+ "open": board["count"], # open + active
78
+ "total": board["total"],
79
+ "stale": board["counts"]["stale"],
80
+ "stale_slugs": [t["slug"] for t in board["tasks"] if t["stale"]][:8],
81
+ }
82
+
83
+ recent: dict[str, Any] = {"days": days, "count": len(commits), "commits": commits}
84
+ if changes_error:
85
+ recent["error"] = changes_error
86
+
87
+ return {
88
+ "ok": True,
89
+ "vault_doc_count": len(idx.docs),
90
+ "recent_changes": recent,
91
+ "docs_to_review": {
92
+ "after_days": review_after_days,
93
+ "count": len(stale),
94
+ "docs": stale,
95
+ },
96
+ "inbox": {"count": inbox_count},
97
+ "tasks": tasks_summary,
98
+ }
@@ -0,0 +1,45 @@
1
+ """Emit the repo's command surface — bound to cordon's reference emitter.
2
+
3
+ The "Code/guards" leg of emit-once, render-many (see the vault decision record
4
+ `report-emit-once-render-many` and `docs/federated-retrieval.md`): the argparse
5
+ parser in `cli.build_parser` *is* the command surface, so we introspect it rather
6
+ than restate it in prose. One emitter, three consumers — an AI session reads the
7
+ JSON token-minimally instead of parsing `AGENTS.md`, a TUI renders it as a command
8
+ picker, and a guard can diff it. It can't drift from `--help` because it is
9
+ generated from the same parser that produces `--help`.
10
+
11
+ This is now a thin binding over **cordon's Python reference emitter**
12
+ (`cordon_emit`, the `cordon-emit` dependency) rather than a private copy: cordon
13
+ owns the algorithm and the schema, and we converge on its output, so
14
+ `tools describe --repos` folds this CLI in as a homogeneous sibling and validates
15
+ every member against the one `cordon-v4.json`. Per-command blast radius is
16
+ declared with `cordon_emit.set_effect` on each subparser in `cli.build_parser`;
17
+ the emitter reads it back here. All this module adds is this repo's inventory
18
+ coordinates (`group` / `order`).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ from typing import Any
25
+
26
+ from cordon_emit import describe_parser as _emit
27
+
28
+ # This MCP's coordinates when folded into the federated surface. Uniqueness of
29
+ # `order` is enforced within a repo's own tools, not across siblings, so a fixed
30
+ # pair is correct: this repo presents as one sibling, not a tool list.
31
+ GROUP = "Vault MCP"
32
+ ORDER = 1
33
+
34
+
35
+ def describe_parser(parser: argparse.ArgumentParser) -> dict[str, Any]:
36
+ """Project the CLI parser to a complete Cordon v4 document via cordon's emitter.
37
+
38
+ Returns the full ``{ok, schema_version, ...}`` envelope. Tool-level effect is
39
+ ``read`` (the entry point only dispatches); per-command effects come from the
40
+ ``set_effect`` annotations in :func:`cli.build_parser`.
41
+ """
42
+ return _emit(parser, group=GROUP, order=ORDER)
43
+
44
+
45
+ __all__ = ["describe_parser"]
@@ -0,0 +1,202 @@
1
+ """Configuration from TOML plus environment-variable overrides.
2
+
3
+ The package is single-user by design: it runs locally as a stdio MCP server
4
+ and reads files the local account can already read. A config file keeps
5
+ personal vault paths and integration details out of the repository, while
6
+ environment variables remain convenient for tests and one-off runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tomllib
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ DEFAULT_CONFIG_PATH = "~/.config/severino-vault-mcp/config.toml"
18
+
19
+
20
+ def _expand_path(raw: str | Path) -> Path:
21
+ return Path(os.path.expanduser(str(raw)))
22
+
23
+
24
+ def _env_path(name: str, default: str | Path) -> Path:
25
+ raw = os.environ.get(name)
26
+ if raw is None:
27
+ return _expand_path(default)
28
+ return _expand_path(raw)
29
+
30
+
31
+ def _env_list(name: str, default: tuple[str, ...] | list[str]) -> tuple[str, ...]:
32
+ raw = os.environ.get(name)
33
+ if raw is None:
34
+ return tuple(default)
35
+ return tuple(part for part in raw.split(":") if part)
36
+
37
+
38
+ def _env_bool(name: str, default: bool = False) -> bool:
39
+ raw = os.environ.get(name)
40
+ if raw is None:
41
+ return default
42
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
43
+
44
+
45
+ def _read_config(path: Path) -> dict[str, Any]:
46
+ try:
47
+ with path.open("rb") as handle:
48
+ data = tomllib.load(handle)
49
+ except FileNotFoundError:
50
+ return {}
51
+ except (OSError, tomllib.TOMLDecodeError):
52
+ return {}
53
+ return data if isinstance(data, dict) else {}
54
+
55
+
56
+ def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
57
+ value = data.get(name, {})
58
+ return value if isinstance(value, dict) else {}
59
+
60
+
61
+ def _value(section: dict[str, Any], key: str, default: Any) -> Any:
62
+ value = section.get(key, default)
63
+ return default if value is None else value
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Config:
68
+ vault_path: Path
69
+ indexed_dirs: tuple[str, ...]
70
+ daily_notes_dir: str
71
+ aliases_path: Path
72
+ topology_path: Path
73
+ infra_datasets_path: Path
74
+ metadata_url: str
75
+ cache_seconds: int
76
+ allow_secret_adjacent_unlock: bool
77
+ secret_unlock_hash: str | None
78
+ secret_unlock_hash_file: Path
79
+ secret_unlock_keychain_service: str
80
+ secret_unlock_keychain_account: str
81
+ secret_unlock_audit_log: Path
82
+
83
+ @classmethod
84
+ def from_env(cls) -> Config:
85
+ config_path = _env_path("SVMC_CONFIG", DEFAULT_CONFIG_PATH)
86
+ data = _read_config(config_path)
87
+ vault = _section(data, "vault")
88
+ metadata = _section(data, "metadata")
89
+ cache = _section(data, "cache")
90
+ # The `secret_adjacent` config section and the SVMC_SECRET_ADJACENT_*
91
+ # env vars below are a backward-compat shim from the rename to
92
+ # `restricted` (see schema.py). Retire them — the second leg of each
93
+ # `os.environ.get(..., os.environ.get("SVMC_SECRET_ADJACENT_...))` — once
94
+ # no local config predates 2026-06; tracked as cleanup, not load-bearing.
95
+ unlock = _section(data, "restricted") or _section(data, "secret_adjacent")
96
+
97
+ indexed_dirs = _value(
98
+ vault,
99
+ "indexed_dirs",
100
+ # "07 Backlog" holds the cross-cutting task slice; project tasks
101
+ # live under "01 Projects/<project>/tasks/" and are already covered.
102
+ ["01 Projects", "02 Infrastructure", "03 Runbooks", "07 Backlog"],
103
+ )
104
+ if isinstance(indexed_dirs, str):
105
+ indexed_dirs = [part for part in indexed_dirs.split(":") if part]
106
+
107
+ vault_path = _env_path(
108
+ "SVMC_VAULT_PATH",
109
+ _value(vault, "path", "~/Documents/vault"),
110
+ )
111
+
112
+ aliases = _section(data, "aliases")
113
+ aliases_default = vault_path / ".svmc" / "aliases.toml"
114
+
115
+ topology = _section(data, "topology")
116
+ topology_default = (
117
+ vault_path / "02 Infrastructure" / "Topology" / "topology.json"
118
+ )
119
+
120
+ infra = _section(data, "infra_datasets")
121
+ infra_default = vault_path / "02 Infrastructure" / "_infra-datasets.json"
122
+
123
+ return cls(
124
+ vault_path=vault_path,
125
+ daily_notes_dir=str(
126
+ os.environ.get(
127
+ "SVMC_DAILY_NOTES_DIR",
128
+ _value(vault, "daily_notes_dir", "00 Inbox/Daily Note"),
129
+ )
130
+ ),
131
+ aliases_path=_env_path(
132
+ "SVMC_ALIASES_PATH",
133
+ _value(aliases, "path", aliases_default),
134
+ ),
135
+ topology_path=_env_path(
136
+ "SVMC_TOPOLOGY_PATH",
137
+ _value(topology, "path", topology_default),
138
+ ),
139
+ infra_datasets_path=_env_path(
140
+ "SVMC_INFRA_DATASETS_PATH",
141
+ _value(infra, "path", infra_default),
142
+ ),
143
+ indexed_dirs=_env_list("SVMC_INDEXED_DIRS", tuple(indexed_dirs)),
144
+ metadata_url=os.environ.get(
145
+ "SVMC_METADATA_URL",
146
+ str(_value(metadata, "url", "")),
147
+ ),
148
+ cache_seconds=int(os.environ.get(
149
+ "SVMC_CACHE_SECONDS",
150
+ str(_value(cache, "seconds", 30)),
151
+ )),
152
+ allow_secret_adjacent_unlock=_env_bool(
153
+ "SVMC_ALLOW_RESTRICTED_UNLOCK",
154
+ _env_bool(
155
+ "SVMC_ALLOW_SECRET_ADJACENT_UNLOCK",
156
+ bool(_value(unlock, "allow_unlock", False)),
157
+ ),
158
+ ),
159
+ secret_unlock_hash=os.environ.get(
160
+ "SVMC_RESTRICTED_UNLOCK_HASH",
161
+ os.environ.get(
162
+ "SVMC_SECRET_ADJACENT_UNLOCK_HASH",
163
+ _value(unlock, "hash", None),
164
+ ),
165
+ ),
166
+ secret_unlock_hash_file=_env_path(
167
+ "SVMC_RESTRICTED_UNLOCK_HASH_FILE",
168
+ os.environ.get(
169
+ "SVMC_SECRET_ADJACENT_UNLOCK_HASH_FILE",
170
+ _value(
171
+ unlock,
172
+ "hash_file",
173
+ "~/.config/severino-vault-mcp/restricted-unlock.sha256",
174
+ ),
175
+ ),
176
+ ),
177
+ secret_unlock_keychain_service=os.environ.get(
178
+ "SVMC_RESTRICTED_UNLOCK_KEYCHAIN_SERVICE",
179
+ os.environ.get(
180
+ "SVMC_SECRET_ADJACENT_UNLOCK_KEYCHAIN_SERVICE",
181
+ str(_value(unlock, "keychain_service", "severino-vault-mcp")),
182
+ ),
183
+ ),
184
+ secret_unlock_keychain_account=os.environ.get(
185
+ "SVMC_RESTRICTED_UNLOCK_KEYCHAIN_ACCOUNT",
186
+ os.environ.get(
187
+ "SVMC_SECRET_ADJACENT_UNLOCK_KEYCHAIN_ACCOUNT",
188
+ str(_value(unlock, "keychain_account", "restricted-unlock")),
189
+ ),
190
+ ),
191
+ secret_unlock_audit_log=_env_path(
192
+ "SVMC_RESTRICTED_UNLOCK_AUDIT_LOG",
193
+ os.environ.get(
194
+ "SVMC_SECRET_ADJACENT_UNLOCK_AUDIT_LOG",
195
+ _value(
196
+ unlock,
197
+ "audit_log",
198
+ "~/.local/state/severino-vault-mcp/audit.log",
199
+ ),
200
+ ),
201
+ ),
202
+ )