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.
- severino_vault_engine-0.1.0/.gitignore +5 -0
- severino_vault_engine-0.1.0/PKG-INFO +70 -0
- severino_vault_engine-0.1.0/README.md +45 -0
- severino_vault_engine-0.1.0/pyproject.toml +66 -0
- severino_vault_engine-0.1.0/src/vault_engine/__init__.py +11 -0
- severino_vault_engine-0.1.0/src/vault_engine/atomic_write.py +119 -0
- severino_vault_engine-0.1.0/src/vault_engine/brief_service.py +98 -0
- severino_vault_engine-0.1.0/src/vault_engine/cli_introspect.py +45 -0
- severino_vault_engine-0.1.0/src/vault_engine/config.py +202 -0
- severino_vault_engine-0.1.0/src/vault_engine/context.py +37 -0
- severino_vault_engine-0.1.0/src/vault_engine/core_tools.py +954 -0
- severino_vault_engine-0.1.0/src/vault_engine/daily_notes.py +167 -0
- severino_vault_engine-0.1.0/src/vault_engine/daily_write.py +83 -0
- severino_vault_engine-0.1.0/src/vault_engine/doctor.py +211 -0
- severino_vault_engine-0.1.0/src/vault_engine/frontmatter.py +217 -0
- severino_vault_engine-0.1.0/src/vault_engine/jsonio.py +65 -0
- severino_vault_engine-0.1.0/src/vault_engine/mirror.py +74 -0
- severino_vault_engine-0.1.0/src/vault_engine/paths.py +82 -0
- severino_vault_engine-0.1.0/src/vault_engine/schema.py +196 -0
- severino_vault_engine-0.1.0/src/vault_engine/search.py +143 -0
- severino_vault_engine-0.1.0/src/vault_engine/secret_unlock.py +165 -0
- severino_vault_engine-0.1.0/src/vault_engine/sections.py +222 -0
- severino_vault_engine-0.1.0/src/vault_engine/sensitivity.py +97 -0
- severino_vault_engine-0.1.0/src/vault_engine/tabular.py +73 -0
- severino_vault_engine-0.1.0/src/vault_engine/task_service.py +474 -0
- severino_vault_engine-0.1.0/src/vault_engine/vault.py +233 -0
- severino_vault_engine-0.1.0/src/vault_engine/vault_query_service.py +222 -0
- severino_vault_engine-0.1.0/src/vault_engine/vault_search_service.py +141 -0
- severino_vault_engine-0.1.0/src/vault_engine/vault_write_service.py +382 -0
- severino_vault_engine-0.1.0/tests/test_education_profile.py +63 -0
- severino_vault_engine-0.1.0/tests/test_jsonio.py +46 -0
- severino_vault_engine-0.1.0/tests/test_schema.py +69 -0
- severino_vault_engine-0.1.0/tests/test_tabular.py +45 -0
- severino_vault_engine-0.1.0/tests/test_task_service.py +210 -0
|
@@ -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
|
+
)
|