Support external vault roots across tooling

This commit is contained in:
2026-09-20 17:05:21 +02:00
parent 1cb6369282
commit 293b582e97
16 changed files with 573 additions and 204 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
__pycache__/
*.pyc
.claude/settings.local.json
.claude/
.idea/
.codex/
+21 -9
View File
@@ -9,7 +9,19 @@ last_reviewed: 2026-06-16
# Agent Query Guide
`index/graph.json` is for navigation. You can query tags or types there. Use it!
`index/graph.json` is the selected vault's navigation index. MCP is the canonical
cross-repository interface: `graph_summary`, `graph_search_nodes`,
`graph_neighbors`, and `read_note`; use `refresh_graph(validate=true)` to update it.
MCP-only agents use the tools above; shell examples below are for operators,
not agent fallback. For those queries, explicitly set `KNOWLEDGE_BASE_ROOT` and change into
that directory. Framework commands require `KNOWLEDGE_BASE_FRAMEWORK_ROOT` as
an explicit path; skill or agent file locations do not identify the data vault.
```bash
: "${KNOWLEDGE_BASE_ROOT:?Set the data vault directory explicitly}"
cd -- "$KNOWLEDGE_BASE_ROOT"
```
Schema:
@@ -22,7 +34,7 @@ Schema:
- `id` (string): stable note identifier (usually from frontmatter)
- `title` (string): note title
- `path` (string): vault-relative markdown path
- `repo` (string): repo scope (`tenant-controller`, `controller-util`, `global`, ...)
- `repo` (string): repo scope (a repository identifier, or `global`)
- `type` (string): note classification from a controlled vocabulary —
`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`;
absent frontmatter means `note`, and `--validate` fails on anything else
@@ -58,7 +70,7 @@ optional JSON brace suffix carrying the relationship and annotation:
see-also, source`.
- Legacy `[[wiki links]]` are **no longer accepted**: `generate-graph.py --validate`
fails on any that remain. Locate and convert them with
`python3 scripts/list-wiki-links.py`.
`python3 "$KNOWLEDGE_BASE_FRAMEWORK_ROOT/scripts/list-wiki-links.py" --vault-root "$KNOWLEDGE_BASE_ROOT"`.
Examples with `jq`:
@@ -74,17 +86,17 @@ jq -r '[.nodes[].tags[]?] | group_by(.) | map({tag: .[0], count: length})
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json
# notes in one repo
jq -r '.nodes[] | select(.repo=="tenant-controller") | "\(.id)\t\(.path)"' index/graph.json
jq -r '.nodes[] | select(.repo=="<repo>") | "\(.id)\t\(.path)"' index/graph.json
# all projects (PARA category 1)
jq -r '.nodes[] | select(.type=="project") | "\(.id)\t\(.path)"' index/graph.json
# outgoing links from one note
NOTE_ID="repo/controller-util/info"
NOTE_ID="<note-id>"
jq -r --arg id "$NOTE_ID" '.nodes[] | select(.id==$id) | .outbound_links[]?' index/graph.json
# incoming links to one note
NOTE_ID="repo/controller-util/info"
NOTE_ID="<note-id>"
jq -r --arg id "$NOTE_ID" '.edges[] | select(.to==$id) | .from' index/graph.json | sort -u
# outbound edges of a note WITH relationship type + annotation
@@ -104,11 +116,11 @@ Validation:
```bash
# validate: non-zero exit on unresolved links, edge types or note types
# outside their vocabularies, OR any remaining legacy [[wiki links]]
python3 scripts/generate-graph.py --validate
python3 "$KNOWLEDGE_BASE_FRAMEWORK_ROOT/scripts/generate-graph.py" --vault-root "$KNOWLEDGE_BASE_ROOT" --validate
# regenerate graph and validate
python3 scripts/generate-graph.py && python3 scripts/generate-graph.py --validate
python3 "$KNOWLEDGE_BASE_FRAMEWORK_ROOT/scripts/generate-graph.py" --vault-root "$KNOWLEDGE_BASE_ROOT" && python3 "$KNOWLEDGE_BASE_FRAMEWORK_ROOT/scripts/generate-graph.py" --vault-root "$KNOWLEDGE_BASE_ROOT" --validate
# list any remaining legacy [[wiki links]] (rejected by --validate) to convert
python3 scripts/list-wiki-links.py
python3 "$KNOWLEDGE_BASE_FRAMEWORK_ROOT/scripts/list-wiki-links.py" --vault-root "$KNOWLEDGE_BASE_ROOT"
```
+7 -6
View File
@@ -1,4 +1,6 @@
# Run framework tools and install integrations against the selected data vault.
PYTHON ?= python3
VAULT_ROOT ?= $(if $(KNOWLEDGE_BASE_ROOT),$(KNOWLEDGE_BASE_ROOT),$(CURDIR))
CLAUDE_HOME ?= $(HOME)/.claude
MCP_NAME ?= knowledge-database
@@ -8,18 +10,17 @@ test:
$(PYTHON) -m unittest discover -s tests
generate-graph:
$(PYTHON) scripts/generate-graph.py
$(PYTHON) scripts/generate-graph.py --vault-root "$(VAULT_ROOT)"
validate-graph:
$(PYTHON) scripts/generate-graph.py --validate
$(PYTHON) scripts/generate-graph.py --vault-root "$(VAULT_ROOT)" --validate
graph: generate-graph validate-graph
install-hooks:
chmod +x scripts/pre-commit
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
$(PYTHON) scripts/install-hooks.py --vault-root "$(VAULT_ROOT)"
# Link this vault's skills and agents into the global Claude context
# Link this framework's skills and agents into the global Claude context
# (~/.claude/skills, ~/.claude/agents) so they are invocable from any repo.
install-global-claude:
mkdir -p "$(CLAUDE_HOME)/skills" "$(CLAUDE_HOME)/agents"
@@ -40,5 +41,5 @@ install-global-claude:
# so it is available from any repo). Re-run to update; removes stale entry first.
install-mcp-claude:
-claude mcp remove --scope user "$(MCP_NAME)" 2>/dev/null
claude mcp add --scope user "$(MCP_NAME)" -- $(PYTHON) "$(CURDIR)/scripts/mcp-server.py"
claude mcp add --scope user "$(MCP_NAME)" -- $(PYTHON) "$(CURDIR)/scripts/mcp-server.py" --vault-root "$(VAULT_ROOT)"
@echo "mcp -> $(MCP_NAME) ($(CURDIR)/scripts/mcp-server.py)"
+48
View File
@@ -44,6 +44,54 @@ make graph # generate index/graph.json and validate it
make test # run the tooling tests
```
## External data vault
Keep this repository as the control plane (tools, skills, agents, hooks, tests,
operational documentation), and store notes in a separate data repository.
The empty PARA skeleton also supports self-contained use.
All Python commands select data with `--vault-root`, then the
`KNOWLEDGE_BASE_ROOT` environment variable, then this framework's root.
An explicit root must be an existing directory; Git operations require it to
be the Git repository root. The working directory does not select the vault.
```sh
python3 /path/to/framework/scripts/mcp-server.py --vault-root /path/to/vault
make -C /path/to/framework graph VAULT_ROOT=/path/to/vault
make -C /path/to/framework install-hooks VAULT_ROOT=/path/to/vault
make -C /path/to/framework install-mcp-claude VAULT_ROOT=/path/to/vault
```
`VAULT_ROOT` defaults to `KNOWLEDGE_BASE_ROOT`, then the framework directory.
The hook installer honors Git's hooks directory (including worktrees and
`core.hooksPath`), preserves unmanaged hooks, and writes a wrapper pointing at
framework tooling. Reinstall after moving the framework or vault. The hook
regenerates and validates the selected vault, then rejects unstaged graph changes.
Configure each stdio MCP client with command `python3` and arguments
`["/path/to/framework/scripts/mcp-server.py", "--vault-root", "/path/to/vault"]`.
MCP reads, writes and Git operations use that vault; `refresh_graph` invokes
the framework generator by absolute path and writes the selected graph even
when validation is requested. A README resource is advertised only if present.
Global skill and agent symlinks must point into this framework. MCP is their
only interface for vault access; unavailable operations are reported without
shell fallback. The CLI examples here are for operators. No personal taxonomy is assumed.
Agents call `read_instructions` to load framework `AGENTS.md` and
`AGENT_QUERY_GUIDE.md` even before a data graph exists. This read-only tool
returns those two framework documents; `read_note` remains vault-scoped.
For migration, test the external root first, back up MCP configurations and
symlink targets, cut clients and hooks over, then smoke-test initialize,
tools/list, graph_summary, search, read and refresh. Only remove old tool copies
after this passes. On failure restore the configs and symlinks and retain the
old copies. Restart existing clients to load the new server command.
A minimal data repository contains `01_projects/` through `04_archives/`,
`index/graph.json` and repository hygiene. Before removing a root hub, replace
its inbound links with links between PARA hubs and validate the graph. Preserve
untracked files separately from the tracked-file cleanup.
## Generation
- Run `make graph`
- Output is written to `index/graph.json`
+16 -18
View File
@@ -26,6 +26,9 @@ through MCP.
## MCP tools
- `read_instructions` — framework `AGENTS.md` and `AGENT_QUERY_GUIDE.md`.
Call this first and read both; report failure if unavailable. These documents
live outside the data graph, and the server locates them without a guessed path.
- `graph_summary` — overview of the graph: counts, repo scopes, the tag
vocabulary (`by_tag`, most frequent first) and central nodes.
- `graph_search_nodes` — find nodes by tag / type / repo / id / text.
@@ -39,7 +42,7 @@ through MCP.
Given a **topic or task**, return a complete, cited briefing:
1. **Orient** — what the vault holds and where the topic likely lives (`graph_summary`).
2. **Find** — every relevant note, via `graph_search_nodes` (+ text-search fallback).
2. **Find** — every relevant note, via `graph_search_nodes`, then `read_note` for candidate note bodies.
3. **Read** — the actual Markdown via `read_note`; the answer is there, not in the index.
4. **Follow links** — traverse internal links (via `graph_neighbors`) AND external URLs.
5. **Synthesize** — one briefing, every claim cited to a note path/id.
@@ -48,14 +51,14 @@ Given a **topic or task**, return a complete, cited briefing:
```
01_projects/ active efforts with a goal and an end
02_areas/ ongoing standards: policies, value-stream areas, decisions (ADRs)
03_resources/meta/ taxonomy + note templates
03_resources/repos/<repo>/ per-repo info.md (+ decisions/)
03_resources/repos/repo-landscape.md index of documented repos
02_areas/ ongoing responsibilities and standards
03_resources/ reference material
04_archives/ inactive items
README.md vault hub
```
Framework owns agents, skills and tooling. The MCP server selects the data vault;
its directory is independent of this agent definition's location.
Every note carries taxonomy frontmatter: `id`, `title`, `type`
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
`generate-graph.py --validate`), `status`
@@ -68,23 +71,18 @@ Legacy `[[wiki links]]` are rejected by the graph validator and must not be writ
Start broad with `graph_summary` to see node counts, repo scopes, the tag
vocabulary and the most-central notes. Its `by_tag` list is the only place the
tag spellings are visible — read it before filtering by `tag`, which matches
verbatim. Then `read_note` the entry points:
- vault hub (`README.md`) — entry points.
- `03_resources/repos/repo-landscape.md` — index of documented repos, grouped.
- `02_areas/ways-of-working.md` — global engineering policy.
- `03_resources/meta/taxonomy.md` — frontmatter fields and PARA buckets.
For a repo topic, `read_note` on `03_resources/repos/<repo>/info.md`. If
`graph_search_nodes` returns no node for it, say it is not yet documented.
verbatim. Find hubs with `graph_search_nodes(type="hub")` and read relevant
ones with `read_note`. Discover policies and repo scopes through the graph;
do not assume a root README or any particular taxonomy subfolders exist.
If no relevant node exists, say the topic is not yet documented.
## 2. Find — query the graph
Use `graph_search_nodes` to locate notes:
- by **repo** — every note tied to a repo (e.g. `tenant-controller`).
- by **repo** — every note tied to a repo (e.g. `<repo>`).
- by **type** — `policy`, `decision`, `feature`, `guideline`, `project`, ...
- by **tag** — e.g. `kcp`. Exact match; take the spelling from `by_tag`.
- by **tag** — e.g. a value from `by_tag`. Exact match; take the spelling from `by_tag`.
- by **text** — `query` matches a node's id, title, path and tags, not the note
body. Widen with a tag or repo filter rather than a longer phrase.
@@ -119,7 +117,7 @@ Links are the intended navigation. Traverse, do not guess.
- Answer the topic/task directly from what the notes say.
- Cite every claim by vault-relative `path` (e.g.
`03_resources/repos/controller-util/info.md`) and/or `id`.
`03_resources/<resource>/index.md`) and/or `id`.
- Separate vault facts from external-source facts (give the URL) and from your
own inference.
- Flag `status: draft`/`deprecated` notes. If the vault does not cover part of
+19 -22
View File
@@ -25,6 +25,8 @@ already captured into `raw/` (a PDF, an image) at the absolute path
## MCP tools
Read:
- `read_instructions` — framework `AGENTS.md` and `AGENT_QUERY_GUIDE.md`,
available without a graph or a data-vault copy of either document.
- `graph_summary` — counts, repo scopes, the tag vocabulary (`by_tag`), most-linked notes.
- `graph_search_nodes` — find notes by tag / type / repo / id / free text.
- `graph_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`.
@@ -42,8 +44,9 @@ Write:
- `edit_note(path, old_string, new_string, replace_all?)` — exact-string edit,
for backlinks into notes that already exist.
Publish:
- `refresh_graph(validate=true)` — regenerate and validate `index/graph.json`.
Publish only when the caller explicitly authorizes it:
- `git_commit(message, paths[])` — stage the named paths and commit.
- `git_push(remote?, branch?)` — push to `origin` / current branch.
@@ -55,21 +58,14 @@ Invoke the skill first, then follow it step by step:
Skill(knowledge-base-ingest-resource)
```
The skill is the source of truth for the procedure. Translate each of its shell
steps into the MCP equivalent:
Then call `read_instructions` and read both documents before any vault mutation.
The MCP server locates the framework; no filesystem access or path guessing is
needed. If instructions cannot be loaded, report the failure before writing.
Use the skill's explicit graph queries to discover additional vault policies.
Apply task-specific instructions supplied by the caller as well.
| Skill step | Your call |
|-------------------------------------|--------------------------------------------------|
| check slug not taken / find matches | `graph_search_nodes`, `read_note` |
| `printf … > raw/source.url` | `write_note("03_resources/<slug>/raw/source.url", …)` |
| save fetched page to `raw/page.md` | `WebFetch` → `write_note(".../raw/page.md", …)` |
| `cp "<source-path>" raw/` | `capture_raw(path=".../raw/<file>", source_path="<source-path>")` |
| `curl -fsSL "<url>" -o raw/<file>` | `capture_raw(path=".../raw/<file>", url="<url>")` |
| read the captured binary | `Read(<absolute_path from capture_raw>)` |
| write the topic + index notes | `write_note` |
| backlink an existing note | `edit_note` |
| `make graph` | `refresh_graph(validate=true)` |
| `git add <path>` (one by one) | `git_commit(message, paths=[…])` |
The skill is the source of truth for the procedure. Follow its MCP calls directly;
do not translate missing operations into shell commands.
## Operating rules
@@ -84,10 +80,10 @@ steps into the MCP equivalent:
Never fabricate a page or transcript — if the fetch or capture fails, stop and
report what is missing. Verify the capture landed: the result carries `bytes`
and `sha256`.
- **YouTube is still out of reach.** Transcripts need `yt-dlp` or
`youtube-transcript-api`, which are shell tools you do not have. Capture the
URL into `raw/source.url`, then hand the video to **the-collector** rather than
inventing transcript text.
- **YouTube transcripts must be supplied.** Capture a supplied transcript with
`capture_raw` or `write_note`. If none is supplied, preserve the source URL and
report the missing transcript before writing content notes. Do not run shell
extractors or invent transcript text.
- **Link, don't duplicate.** Search the graph before writing a topic note. If the
vault already covers the topic, link it instead of creating a second note.
- **Typed markdown links only.** `[title](note-id){"type":"…","link-info":"…"}`.
@@ -100,9 +96,10 @@ steps into the MCP equivalent:
- **Name every path you commit.** `git_commit` takes an explicit list; wildcard
staging is rejected by the server. Include the resource folder, every
backlinked note, and `index/graph.json`.
- **Push straight to `main`.** This vault takes ingests as direct commits; no
branch, no PR. Commit with Conventional Commits (`feat: ingest <resource>`),
then `git_push`. If the caller says "don't push", stop after the commit.
- **Follow the caller's publishing instructions.** Do not commit or push unless
explicitly authorized. Use the selected vault's branch and review policy;
do not assume a branch name or a direct-push workflow. Without publishing
authorization, report the changed paths and validation result.
## Report
+4 -2
View File
@@ -18,6 +18,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Tuple
from vault_root import parse_vault_args
# Obsidian-style wiki link: [[target#anchor|alias]]
WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
@@ -438,7 +440,7 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="write index/graph.json (default unless --validate is used alone)",
)
return parser.parse_args()
return parse_vault_args(parser)
def print_unresolved(unresolved: List[UnresolvedLink]) -> None:
@@ -502,7 +504,7 @@ def print_invalid_node_types(invalid: List[Dict[str, object]]) -> None:
def main() -> None:
args = parse_args()
root = Path(__file__).resolve().parents[1]
root = args.vault_root
output = root / "index" / "graph.json"
should_write = args.write or not args.validate
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Install a vault-local hook that invokes this framework without copying its tools."""
import argparse
import shlex
import subprocess
from pathlib import Path
from vault_root import FRAMEWORK_ROOT, parse_vault_args
def main():
parser = argparse.ArgumentParser(description=__doc__)
root = parse_vault_args(parser).vault_root
repo = subprocess.run(["git", "-C", str(root), "rev-parse", "--show-toplevel"],
capture_output=True, text=True)
if repo.returncode or repo.stdout.strip() != str(root):
parser.error("vault root must be the Git repository root to install hooks")
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-path", "hooks"],
capture_output=True, text=True, check=True,
)
hook = Path(result.stdout.strip()) / "pre-commit"
marker = "# knowledge-base framework hook"
if hook.exists() or hook.is_symlink():
legacy = hook.is_symlink() and hook.resolve() in {
root / "scripts/pre-commit", FRAMEWORK_ROOT / "scripts/pre-commit",
}
managed = not hook.is_symlink() and marker in hook.read_text()
if not legacy and not managed:
parser.error(f"refusing to replace unmanaged hook: {hook}")
script = FRAMEWORK_ROOT / "scripts/pre-commit"
content = f"#!/bin/sh\n{marker}\nexec bash {shlex.quote(str(script))} {shlex.quote(str(root))}\n"
hook.parent.mkdir(parents=True, exist_ok=True)
if hook.is_symlink():
hook.unlink()
hook.write_text(content)
hook.chmod(0o755)
print(f"Installed {hook} -> {script} (vault: {root})")
if __name__ == "__main__":
main()
+4 -2
View File
@@ -21,6 +21,8 @@ from collections import defaultdict
from pathlib import Path
from typing import Dict, List
from vault_root import parse_vault_args
# Reuse the parser/resolver from generate-graph.py (hyphenated name -> load by path).
_GG_PATH = Path(__file__).resolve().with_name("generate-graph.py")
_spec = importlib.util.spec_from_file_location("generate_graph", _GG_PATH)
@@ -102,9 +104,9 @@ def main() -> None:
default="grouped",
help="output format (default: grouped text)",
)
args = parser.parse_args()
args = parse_vault_args(parser)
root = Path(__file__).resolve().parents[1]
root = args.vault_root
rows = collect(root)
if args.format == "tsv":
print_tsv(rows)
+48 -5
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""MCP server for the knowledge-base repository.
"""MCP server for a selected knowledge-base vault, using framework-owned tooling.
This server is the single interface an agent needs to work the vault over stdio
with JSON-RPC 2.0. It exposes graph and note *querying* (graph_summary,
with JSON-RPC 2.0. It provides framework operating instructions (read_instructions),
and exposes graph and note *querying* (graph_summary,
graph_search_nodes, graph_neighbors, read_note, refresh_graph), *writing*
(write_note for text, capture_raw for verbatim bytes from a local file or an
http(s) download) and *publishing* (edit_note, git_commit, git_push), so an
@@ -18,6 +19,7 @@ It auto-detects the transport layer:
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
@@ -30,6 +32,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
from vault_root import FRAMEWORK_ROOT, parse_vault_args
SERVER_NAME = "knowledge-base-mcp"
SERVER_VERSION = "0.1.0"
@@ -227,6 +230,12 @@ def _relative_to_root(state: AppState, target: Path) -> str:
def _run_git(state: AppState, args: List[str]) -> subprocess.CompletedProcess:
repo = subprocess.run(
["git", "rev-parse", "--show-toplevel"], cwd=state.root,
check=False, capture_output=True, text=True,
)
if repo.returncode != 0 or Path(repo.stdout.strip()).resolve() != state.root.resolve():
raise ValueError("vault root must be the Git repository root for Git operations")
return subprocess.run(
["git", *args],
cwd=state.root,
@@ -249,6 +258,19 @@ def _git_result(payload: Dict[str, Any], exit_code: int) -> Dict[str, Any]:
def _tool_definitions() -> List[Dict[str, Any]]:
return [
{
"name": "read_instructions",
"description": (
"Read framework AGENTS.md and AGENT_QUERY_GUIDE.md before working "
"with the vault. These operating documents live outside an "
"external vault and do not require a generated graph."
),
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
{
"name": "graph_summary",
"description": (
@@ -474,6 +496,15 @@ def _text_result(text: str, structured: Dict[str, Any] | None = None) -> Dict[st
return result
def _tool_read_instructions() -> Dict[str, Any]:
documents = [
{"path": name, "content": (FRAMEWORK_ROOT / name).read_text(encoding="utf-8")}
for name in ("AGENTS.md", "AGENT_QUERY_GUIDE.md")
]
payload = {"framework_root": str(FRAMEWORK_ROOT), "documents": documents}
return _text_result(json.dumps(payload, indent=2), structured=payload)
def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
graph = _load_graph(state)
nodes = graph.get("nodes", [])
@@ -624,7 +655,10 @@ def _tool_read_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
def _tool_refresh_graph(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
command = [sys.executable, "scripts/generate-graph.py"]
command = [
sys.executable, str(FRAMEWORK_ROOT / "scripts/generate-graph.py"),
"--vault-root", str(state.root), "--write",
]
if bool(args.get("validate", False)):
command.append("--validate")
@@ -884,6 +918,8 @@ def _tool_git_push(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
if name == "read_instructions":
return _tool_read_instructions()
if name == "graph_summary":
return _tool_graph_summary(state, arguments)
if name == "graph_search_nodes":
@@ -908,7 +944,7 @@ def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[st
def _list_resources(state: AppState) -> Dict[str, Any]:
return {
result = {
"resources": [
{
"uri": "repo://graph",
@@ -924,6 +960,12 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
},
]
}
if not (state.root / "README.md").is_file():
result["resources"] = [
resource for resource in result["resources"]
if resource["uri"] != "repo://readme"
]
return result
def _read_resource(state: AppState, uri: str) -> Dict[str, Any]:
@@ -994,7 +1036,8 @@ def _handle_request(state: AppState, message: Dict[str, Any]) -> Dict[str, Any]
def main() -> None:
root = Path(__file__).resolve().parents[1]
args = parse_vault_args(argparse.ArgumentParser(description=__doc__))
root = args.vault_root
state = AppState(root=root, graph_path=root / "index" / "graph.json")
reader = _StdinReader(sys.stdin.buffer)
+7 -3
View File
@@ -1,11 +1,15 @@
#!/usr/bin/env bash
# Generate and validate the selected vault graph before accepting a commit.
set -euo pipefail
framework_root="$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/.." && pwd)"
vault_root="${1:-${KNOWLEDGE_BASE_ROOT:-$framework_root}}"
echo "[pre-commit] Generating and validating graph..."
make graph
"${PYTHON:-python3}" "$framework_root/scripts/generate-graph.py" --vault-root "$vault_root" --write --validate
if ! git diff --quiet -- index/graph.json; then
if ! git -C "$vault_root" ls-files --error-unmatch index/graph.json >/dev/null 2>&1 || \
! git -C "$vault_root" diff --quiet -- index/graph.json; then
echo "[pre-commit] index/graph.json was modified by generation."
echo "[pre-commit] Run 'make graph' and stage index/graph.json before committing."
echo "[pre-commit] Stage the generated index/graph.json before committing."
exit 1
fi
+27
View File
@@ -0,0 +1,27 @@
"""Resolve the selected data directory consistently for framework command-line tools."""
import argparse
import os
from pathlib import Path
FRAMEWORK_ROOT = Path(__file__).resolve().parents[1]
def parse_vault_args(parser: argparse.ArgumentParser) -> argparse.Namespace:
"""Add the shared CLI contract and return a validated absolute vault root."""
parser.add_argument(
"--vault-root",
help="data directory (default: KNOWLEDGE_BASE_ROOT, then framework root)",
)
args = parser.parse_args()
raw = args.vault_root if args.vault_root is not None else os.environ.get(
"KNOWLEDGE_BASE_ROOT", str(FRAMEWORK_ROOT)
)
if not raw.strip():
parser.error("vault root must not be empty")
root = Path(raw).expanduser().resolve()
if not root.is_dir():
parser.error(f"vault root must be an existing directory: {root}")
args.vault_root = root
return args
+56 -77
View File
@@ -9,8 +9,23 @@ Capture an external resource into the vault as a reference note, store its
original bytes under `raw/`, decompose it into topics, and link those topics into
the knowledge graph. Follow every step.
Obey `AGENTS.md` and `02_areas/ways-of-working.md` (read them if not already
loaded this session; if `ways-of-working.md` does not exist yet, skip it).
Before ingesting, call `read_instructions` on `knowledge-database` and read both
returned documents: the framework's `AGENTS.md` and `AGENT_QUERY_GUIDE.md`.
The server resolves their location; they are not data-vault nodes and must not
be fetched with `read_note`. If this call fails, report the missing instructions
before changing the vault.
Keep the caller's task instructions in scope. For additional vault policies,
query `graph_search_nodes(type="policy")` and `graph_search_nodes(type="guideline")`,
then `read_note` relevant results and follow their policy links. Do not assume
`02_areas/ways-of-working.md` exists; an empty search means no policies were found,
not that framework or caller instructions cease to apply.
The `knowledge-database` MCP server is the only interface for vault access: use `graph_summary`, `graph_search_nodes`, `graph_neighbors` and
`read_note` to find context; `capture_raw` for source bytes; `write_note` and
`edit_note` for notes; `refresh_graph(validate=true)` to regenerate and validate.
Use `git_commit` / `git_push` only when the caller explicitly authorizes them.
Skills stay in the framework. Never infer the vault from this skill's location.
The output is a `type: note` reference note at
`03_resources/<resource-name>/index.md`, one `type: note` **topic note** per
@@ -47,10 +62,9 @@ Pick a short kebab-case `<resource-name>` from the title / filename / video
title. Keep it descriptive and unique (e.g. `cyclonedx-sbom-spec`,
`oauth2-rfc6749`).
```bash
ls 03_resources/ # check the slug is not already taken
mkdir -p 03_resources/<resource-name>/raw
```
Check for an existing resource with `graph_search_nodes(query="<resource-name>")`
and `read_note(path="03_resources/<resource-name>/index.md")`. The capture and
write tools create parent directories; no separate folder-creation step is needed.
If the slug already exists, either you are re-ingesting (update in place, tell the
user) or pick a more specific slug.
@@ -59,47 +73,31 @@ user) or pick a more specific slug.
Always preserve the source bytes untouched. Pick the branch that matches step 1.
**Local file** — copy verbatim, keep the original name:
```bash
cp "<source-path>" "03_resources/<resource-name>/raw/"
```
**Local file** — use `capture_raw` with the supplied `source_path` and a
vault-relative `path` such as `03_resources/<resource-name>/raw/<filename>`.
This preserves the original bytes and filename.
**PDF / binary at a URL** — download verbatim:
```bash
curl -fsSL "<url>" -o "03_resources/<resource-name>/raw/<filename>"
```
**PDF / binary at a URL** — use `capture_raw(url="<url>", path=".../raw/<filename>")`.
Check the returned byte count and SHA-256.
**Webpage / link** — record the URL and save the readable content. Use `WebFetch`
to retrieve it (request markdown), then write the result and a `source.url`:
```bash
printf '%s\n' "<url>" > "03_resources/<resource-name>/raw/source.url"
```
Save the fetched body to `raw/page.md` (write the `WebFetch` output to that file).
**Webpage / link** — save the URL using
`write_note(path=".../raw/source.url", content="<url>\n")`. Capture the original
page with `capture_raw(url="<url>", path=".../raw/page.html")`. If the invoking
agent allows `WebFetch`, it may fetch a readable version of that external source;
save the returned body with `write_note(path=".../raw/page.md", content=...)`.
**YouTube video** — pull the transcript into `raw/`. Record the URL first, then
try, in order:
```bash
printf '%s\n' "<url>" > "03_resources/<resource-name>/raw/source.url"
# Preferred: yt-dlp grabs subs + metadata without downloading the video
yt-dlp --skip-download --write-subs --write-auto-subs --sub-lang en \
--convert-subs srt --write-info-json \
-o "03_resources/<resource-name>/raw/%(title)s.%(ext)s" "<url>"
# Fallback if yt-dlp is absent: python transcript API
python3 -c "import youtube_transcript_api" 2>/dev/null && \
python3 -c "from youtube_transcript_api import YouTubeTranscriptApi as A; \
import sys; vid='<VIDEO_ID>'; \
open('03_resources/<resource-name>/raw/transcript.txt','w').write(\
'\n'.join(s['text'] for s in A.get_transcript(vid)))"
```
If neither tool is installed, tell the user which to install (`pip install yt-dlp`
or `pip install youtube-transcript-api`) and stop before fabricating a transcript.
Never invent transcript text.
**YouTube video** — record the URL with `write_note`. If a transcript is supplied,
capture its file with `capture_raw(source_path=..., path=".../raw/transcript.txt")`
or preserve supplied text with `write_note`. These MCP tools do not extract
YouTube transcripts. If none is supplied, report the missing transcript and stop
before writing content notes. Never invent transcript text or run shell extractors.
## 4. Read the captured content
Read what you just stored in `raw/` (the file, `page.md`, the transcript). This —
Use `read_note(path="03_resources/<resource-name>/raw/<filename>")` for captured
text. Path reads access the file directly and work before graph regeneration.
For a captured binary, use the invoking agent’s explicitly permitted inspection
tool on the absolute path returned by `capture_raw`; if unavailable, report it. This —
not the live URL — is the source of truth for the rest of the steps, so the note
matches the bytes we preserved. For long content, read enough to identify every
distinct topic, not just the intro.
@@ -110,24 +108,10 @@ Reason about the **individual topics** inside the resource — the concrete
concepts, standards, formats, tools, or entities it discusses. For each topic,
decide whether the vault already knows about it, and whether it points outward.
For every candidate topic, search the vault for a matching note:
```bash
# free-text over the graph (id / title / path / tags)
jq -r --arg q "<topic>" '.nodes[]
| select((.id|ascii_downcase|contains($q)) or (.title|ascii_downcase|contains($q)))
| "\(.id)\t\(.path)"' index/graph.json
# by tag
jq -r --arg t "<topic-tag>" '.nodes[] | select(.tags[]? == $t) | "\(.id)\t\(.path)"' index/graph.json
# full-text fallback across the PARA buckets
grep -rIl "<topic>" 01_projects 02_areas 03_resources 04_archives
```
Prefer the `knowledge-database` MCP server when available (`graph_search_nodes`,
`graph_neighbors`, `read_note`) — it is the same index. Open promising matches and
confirm they are really the same concept before linking.
For every candidate topic, use `graph_search_nodes(query="<topic>")` and tag
filters from `graph_summary`, then traverse `graph_neighbors` and `read_note`
promising matches. Confirm they cover the same concept before linking.
The query searches metadata, not note bodies; read candidate notes when needed.
Classify each topic:
- **Internal** — the vault has a note for it → link with
@@ -145,7 +129,7 @@ Classify each topic:
## 6. Write a note per topic
Each distinct topic from step 5 becomes its own note at
Use `write_note` to create each distinct topic from step 5 at
`03_resources/<resource-name>/<topic-name>.md` (kebab-case slug). This is what
makes topics first-class, individually linkable graph nodes.
@@ -188,11 +172,11 @@ the *source* needs from the *destination* — "what to expect there".
- Link each topic note back to its resource with
`[<resource title>](resource/<resource-name>){"type":"part-of"}`, and outward to
internal notes (same link form) and external sources (URLs).
- Legacy `[[wiki links]]` are rejected by `make validate-graph` — never write them.
- Legacy `[[wiki links]]` are rejected by `refresh_graph(validate=true)` — never write them.
## 7. Write the resource note (index)
Filename: `03_resources/<resource-name>/index.md`. This is the hub that ties the
Use `write_note` for `03_resources/<resource-name>/index.md`. This hub ties the
raw source to the topic notes.
```md
@@ -230,7 +214,7 @@ last_reviewed: <today, YYYY-MM-DD>
- `ingested` and `last_reviewed` = today (the `currentDate` from session context).
- `type` must stay within the vault taxonomy enum
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`),
which `generate-graph.py --validate` enforces — use `note`;
which `refresh_graph(validate=true)` enforces — use `note`;
the `resource` tag is what marks it.
- Markdown link targets resolve by `id`, rel-path, title, or unique stem — always
use the `id` as the target, e.g. `[Human title](resource/foo/bar)`.
@@ -244,16 +228,14 @@ So the resource is reachable in the graph, not just reaching out:
the most relevant existing notes (the strong internal topics from step 5) — e.g.
under a `## Resources` or `## Related` section, creating it if absent. Add a
`{"type":"…","link-info":"…"}` brace where the relationship is more specific than
`related`. Use individual edits.
`related`. Use `edit_note` for each existing note.
- If several resources accumulate, maintain `03_resources/index.md` as a hub that
lists them, and link the new note from there (create the hub if it does not
exist, with `type: hub`, `tags: [resource, hub]`).
## 9. Regenerate and validate the graph
```bash
make graph
```
Use `refresh_graph(validate=true)`.
This regenerates `index/graph.json` and validates links and edge relationship
types. If validation reports unresolved links, fix the targets (typos, wrong `id`,
@@ -265,17 +247,14 @@ invalid graph, so this must pass.
## 10. Stage (do not commit unless asked)
Per `ways-of-working.md`, NEVER use `git add -A` or `git add .`. Stage each
touched path individually:
Without explicit publishing authorization, leave the changes uncommitted and
report the touched paths. The MCP interface has no stage-only tool.
```bash
git add 03_resources/<resource-name>/ # note + raw/
git add index/graph.json
git add <any-existing-note-you-backlinked>.md
```
Only commit or open a PR if the user explicitly asks. Changes go via small, clear
pull requests.
When a commit is authorized, use `git_commit(message=..., paths=[...])` with an
explicit list covering the resource folder, backlinked notes and `index/graph.json`.
This tool stages those paths and commits them; do not use wildcard paths.
Use `git_push` only when pushing is explicitly authorized. Follow the caller's
branch and review policy.
## 11. Report
+37 -53
View File
@@ -11,8 +11,18 @@ Markdown is the source of truth; `index/graph.json` is a generated navigation
index. Use this skill to answer questions FROM the vault and to surface the
exact notes that back the answer.
Obey `AGENTS.md` (read them if not loaded this
session). This skill is read-only — do not modify notes here.
Call `read_instructions` on `knowledge-database` to read the framework's
`AGENTS.md` and `AGENT_QUERY_GUIDE.md` before querying the vault. These documents
are not data-vault nodes. If unavailable, report the missing instructions.
This skill is read-only — do not modify notes here.
## Vault access
The `knowledge-database` MCP server is the only interface for vault access. Use `graph_summary` to orient, `graph_search_nodes` to find notes,
`graph_neighbors` to traverse links, and `read_note` to read the source.
Use `refresh_graph` when the generated index is stale. Skills and operational
documentation belong to the framework; never infer the data vault from this
skill's file location.
## Layout (PARA)
@@ -25,84 +35,58 @@ session). This skill is read-only — do not modify notes here.
03_resources/repos/repo-landscape.md index of documented repos
04_archives/ inactive items
index/graph.json generated graph (nodes + edges)
README.md vault hub; AGENT_QUERY_GUIDE.md graph query reference
```
Every note carries taxonomy frontmatter: `id`, `title`, `type`
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
`generate-graph.py --validate`), `status`
`refresh_graph(validate=true)`), `status`
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes are
linked with typed markdown links — `[text](note-id){"type":"…"}` — that resolve
by `id`. Legacy `[[wiki links]]` are rejected by `make validate-graph`.
by `id`. Legacy `[[wiki links]]` are rejected by `refresh_graph(validate=true)`.
## 1. Orient
Start broad, then narrow:
- `README.md` — vault hub and entry points.
- `03_resources/repos/repo-landscape.md` — index of every documented repo, grouped.
- `02_areas/ways-of-working.md` — global engineering policy.
- `03_resources/meta/taxonomy.md` — what the frontmatter fields and PARA buckets mean.
For a repo question, read `03_resources/repos/<repo>/info.md` directly
(`ls 03_resources/repos/` to confirm the repo is documented; if absent, say so —
it is not yet in the vault).
Start broad, then narrow. Begin with `graph_summary`, then find hubs using
`graph_search_nodes(type="hub")` and read relevant ones with `read_note`.
Discover repo scopes and tags from the graph rather than assuming a particular
folder layout. An external data vault need not contain a root README or
framework operational documents.
## 2. Query the graph
`index/graph.json` is for navigation by tag / type / repo / links. Use `jq`.
Full schema and more examples are in `AGENT_QUERY_GUIDE.md`.
Use the MCP tools directly:
```bash
# all documented repos
jq -r '.nodes[].repo' index/graph.json | sort -u
| Need | Tool |
|------|------|
| Repo scopes, tag spellings, most-linked notes | `graph_summary` |
| Notes in a repo | `graph_search_nodes(repo="<repo>")` |
| Notes of a type | `graph_search_nodes(type="policy")` |
| Notes with a tag | `graph_search_nodes(tag="<tag>")` |
| Search ids, titles, paths and tags | `graph_search_nodes(query="<term>")` |
| Incoming/outgoing links, relationship types and annotations | `graph_neighbors(id="<note-id>")` |
# every note in one repo
jq -r '.nodes[] | select(.repo=="tenant-controller") | "\(.id)\t\(.path)"' index/graph.json
# all notes of a type (e.g. policies, decisions, projects)
jq -r '.nodes[] | select(.type=="policy") | "\(.id)\t\(.path)"' index/graph.json
# notes carrying a tag
jq -r '.nodes[] | select(.tags[]? == "kcp") | "\(.id)\t\(.path)"' index/graph.json
# most-linked (central) notes
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)"' index/graph.json | head
# outgoing links from a note, with relationship type + annotation
NOTE_ID="repo/controller-util/info"
jq -r --arg id "$NOTE_ID" '.edges[] | select(.from==$id) | "\(.type)\t\(.to)\t\(.annotation)"' index/graph.json
# incoming links to a note (who references it, and how)
jq -r --arg id "$NOTE_ID" '.edges[] | select(.to==$id) | "\(.type)\t\(.from)\t\(.annotation)"' index/graph.json
# edges of a specific relationship type (e.g. supersedes, depends-on)
jq -r '.edges[] | select(.type=="supersedes") | "\(.from) -> \(.to)"' index/graph.json
```
Full-text fallback when you do not know the tag/type/id:
```bash
grep -rIl "<term>" 01_projects 02_areas 03_resources 04_archives
```
`query` does not search note bodies. Read candidate notes with `read_note` and
follow their neighbors when metadata search is insufficient. Report any search
coverage limits; do not use filesystem or shell searches.
## 3. Read the source notes
The graph gives ids and paths; the ANSWER lives in the Markdown. Open the
matched `.path` files and read them. Follow the typed markdown links
The graph gives ids and paths; the ANSWER lives in the Markdown. Read matched
notes with `read_note(id="<note-id>")` or `read_note(path="<vault-relative-path>")`. Follow the typed markdown links
`[text](id){"type":"…"}`, resolving each to its node `path` to pull in related
policies, decisions, and repo info. Use each edge's `type` and `annotation` to
judge which links are worth following; links are the intended navigation, so
traverse them rather than guessing.
If the graph looks stale or a path is missing, you may regenerate read-only:
`python3 scripts/generate-graph.py` (does not touch Markdown). Do not commit.
If the graph looks stale or a path is missing, use `refresh_graph`. This updates
only the generated index; it does not touch Markdown. Do not commit.
## 4. Answer with citations
- Answer the question directly from what the notes say.
- Cite the backing notes by vault-relative `path` (e.g.
`03_resources/repos/gittea/info.md`) and/or `id` so the user can verify.
`03_resources/<resource>/index.md`) and/or `id` so the user can verify.
- Distinguish vault facts from inference. If the vault does not cover it, say so
plainly — do not invent. Note `status: draft`/`deprecated` when relevant.
- For "what links to / relates to X" questions, report the inbound/outbound
+227
View File
@@ -0,0 +1,227 @@
"""Exercise framework commands against separate vaults to prevent cross-repo writes."""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
FRAMEWORK = Path(__file__).resolve().parents[1]
class ExternalVaultTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.framework = self.base / "framework space"
shutil.copytree(FRAMEWORK / "scripts", self.framework / "scripts")
shutil.copy2(FRAMEWORK / "Makefile", self.framework / "Makefile")
self.vault = self.base / "vault space"
self.other = self.base / "other"
self.env = {k: v for k, v in os.environ.items() if k != "KNOWLEDGE_BASE_ROOT"}
for root, name in [(self.framework, "framework"), (self.vault, "vault"), (self.other, "other")]:
(root / "03_resources").mkdir(parents=True, exist_ok=True)
(root / "03_resources" / "index.md").write_text(
f"---\nid: {name}\ntitle: {name}\ntype: hub\n---\n# {name}\n"
)
self.git(root, "init", "-q", "-b", "main")
self.git(root, "config", "user.email", "test@example.com")
self.git(root, "config", "user.name", "Test")
self.git(root, "config", "commit.gpgsign", "false")
self.git(root, "add", "03_resources/index.md")
self.git(root, "commit", "-qm", "init")
def git(self, root, *args):
return subprocess.run(["git", *args], cwd=root, env=self.env,
capture_output=True, text=True, check=True).stdout.strip()
def run_script(self, name, *args, input="", env=None):
return subprocess.run([sys.executable, str(self.framework / "scripts" / name), *map(str, args)],
cwd=self.other, env=env or self.env, input=input,
capture_output=True, text=True, timeout=15)
def rpc(self, calls, *args, env=None):
requests = [{"jsonrpc": "2.0", "id": i, "method": method, "params": params}
for i, (method, params) in enumerate(calls)]
proc = self.run_script("mcp-server.py", *args, env=env,
input="".join(json.dumps(r) + "\n" for r in requests))
self.assertEqual(proc.returncode, 0, proc.stderr)
responses = [json.loads(line) for line in proc.stdout.splitlines()]
self.assertEqual(len(responses), len(calls), proc.stdout)
for response in responses:
self.assertNotIn("error", response, response)
return [r["result"] for r in responses]
def tool(self, name, arguments=None, root=None):
return self.rpc([("tools/call", {"name": name, "arguments": arguments or {}})],
"--vault-root", root or self.vault)[0]
def test_generator_writes_only_external_vault(self):
proc = self.run_script("generate-graph.py", "--vault-root", self.vault, "--write", "--validate")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
graph = json.loads((self.vault / "index/graph.json").read_text())
self.assertEqual([n["id"] for n in graph["nodes"]], ["vault"])
self.assertFalse((self.framework / "index/graph.json").exists())
self.assertFalse((self.other / "index/graph.json").exists())
def test_instructions_are_discoverable_and_come_from_framework_without_graph(self):
expected = {}
for name in ["AGENTS.md", "AGENT_QUERY_GUIDE.md"]:
expected[name] = f"# Framework {name}\nFramework-owned instructions.\n"
(self.framework / name).write_text(expected[name])
(self.vault / name).write_text("# Unrelated vault file\n")
listed = self.rpc([("tools/list", {})], "--vault-root", self.vault)[0]
self.assertIn("read_instructions", {t["name"] for t in listed["tools"]})
result = self.tool("read_instructions")
self.assertFalse(result.get("isError"), result)
documents = result["structuredContent"]["documents"]
self.assertEqual({d["path"]: d["content"] for d in documents}, expected)
self.assertEqual(result["structuredContent"]["framework_root"], str(self.framework))
self.assertFalse((self.vault / "index/graph.json").exists())
def test_missing_framework_instructions_fail_explicitly_without_vault_fallback(self):
(self.framework / "AGENTS.md").write_text("Read AGENT_QUERY_GUIDE.md\n")
(self.vault / "AGENT_QUERY_GUIDE.md").write_text("Wrong instructions\n")
result = self.tool("read_instructions")
self.assertTrue(result.get("isError"), result)
message = result["content"][0]["text"]
self.assertIn(str(self.framework / "AGENT_QUERY_GUIDE.md"), message)
def test_wiki_inventory_uses_external_vault(self):
for root, target in [(self.vault, "vault-target"), (self.framework, "framework-target")]:
(root / "03_resources/wiki.md").write_text(f"[[{target}]]\n")
proc = self.run_script("list-wiki-links.py", "--vault-root", self.vault)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("vault-target", proc.stdout)
self.assertNotIn("framework-target", proc.stdout)
def test_mcp_reads_writes_and_commits_only_vault(self):
framework_head = self.git(self.framework, "rev-parse", "HEAD")
result = self.tool("write_note", {"path": "03_resources/new.md", "content": "# New\n"})
self.assertFalse(result.get("isError"), result)
read = self.tool("read_note", {"path": "03_resources/new.md"})
self.assertEqual(read["structuredContent"]["content"], "# New\n")
result = self.tool("git_commit", {"message": "add test note", "paths": ["03_resources/new.md"]})
self.assertFalse(result.get("isError"), result)
self.assertEqual(self.git(self.vault, "log", "-1", "--format=%s"), "add test note")
self.assertEqual(self.git(self.framework, "rev-parse", "HEAD"), framework_head)
self.assertFalse((self.framework / "03_resources/new.md").exists())
for tool, args in [("read_note", {"path": "../framework space/03_resources/index.md"}),
("write_note", {"path": "../escape.md", "content": "escape"})]:
self.assertTrue(self.tool(tool, args).get("isError"))
def test_refresh_uses_absolute_framework_generator_and_writes_when_validating(self):
(self.vault / "scripts").mkdir()
(self.vault / "scripts/generate-graph.py").write_text("raise RuntimeError('vault tool used')\n")
result = self.tool("refresh_graph", {"validate": True})
self.assertFalse(result.get("isError"), result)
command = result["structuredContent"]["command"]
self.assertEqual(command[1], str(self.framework / "scripts/generate-graph.py"))
self.assertEqual(command[command.index("--vault-root") + 1], str(self.vault))
self.assertTrue((self.vault / "index/graph.json").exists())
summary = self.tool("graph_summary")
self.assertFalse(summary.get("isError"), summary)
def test_invalid_roots_fail_clearly_for_all_commands(self):
file = self.base / "file"
file.write_text("not a directory")
for script in ["generate-graph.py", "list-wiki-links.py", "mcp-server.py"]:
for root in [self.base / "missing", file, ""]:
with self.subTest(script=script, root=root):
proc = self.run_script(script, "--vault-root", root)
self.assertNotEqual(proc.returncode, 0)
self.assertIn("vault root", proc.stderr.lower())
self.assertNotIn("Traceback", proc.stderr)
def test_root_precedence_and_standalone_for_all_commands(self):
for root, name in [(self.framework, "framework"), (self.vault, "vault"), (self.other, "other")]:
(root / "03_resources/wiki.md").write_text(f"[[{name}-target]]\n")
for args, env, root, name in [([], self.env, self.framework, "framework"),
([], dict(self.env, KNOWLEDGE_BASE_ROOT=str(self.other)), self.other, "other"),
(["--vault-root", str(self.vault)], dict(self.env, KNOWLEDGE_BASE_ROOT=str(self.other)), self.vault, "vault")]:
with self.subTest(name=name):
proc = self.run_script("generate-graph.py", *args, env=env)
self.assertEqual(proc.returncode, 0, proc.stderr)
graph = json.loads((root / "index/graph.json").read_text())
self.assertIn(name, [n["id"] for n in graph["nodes"]])
proc = self.run_script("list-wiki-links.py", *args, env=env)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn(f"{name}-target", proc.stdout)
result = self.rpc([("tools/call", {"name": "read_note", "arguments": {"path": "03_resources/index.md"}})], *args, env=env)[0]
self.assertIn(f"# {name}", result["structuredContent"]["content"])
def test_mcp_protocol_and_optional_readme_resource(self):
self.tool("refresh_graph")
results = self.rpc([("initialize", {}), ("tools/list", {}), ("resources/list", {})], "--vault-root", self.vault)
self.assertEqual(results[0]["serverInfo"]["name"], "knowledge-base-mcp")
self.assertIn("graph_summary", {t["name"] for t in results[1]["tools"]})
self.assertNotIn("repo://readme", {r["uri"] for r in results[2]["resources"]})
(self.vault / "README.md").write_text("# Optional hub\n")
result = self.rpc([("resources/list", {})], "--vault-root", self.vault)[0]
self.assertIn("repo://readme", {r["uri"] for r in result["resources"]})
def test_git_refuses_parent_repository_for_nested_non_repo_vault(self):
nested = self.framework / "nested"
nested.mkdir()
(nested / "note.md").write_text("# note\n")
before = self.git(self.framework, "rev-parse", "HEAD")
result = self.tool("git_commit", {"message": "must fail", "paths": ["note.md"]}, root=nested)
self.assertTrue(result.get("isError"), result)
self.assertEqual(self.git(self.framework, "rev-parse", "HEAD"), before)
def test_make_and_installed_hook_use_selected_vault(self):
def make(*args):
return subprocess.run(["make", "-C", str(self.framework), f"VAULT_ROOT={self.vault}", *args],
env=self.env, capture_output=True, text=True)
proc = make("graph", "install-hooks")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
hook = self.vault / ".git/hooks/pre-commit"
self.assertTrue(hook.exists())
self.git(self.vault, "add", "index/graph.json")
proc = subprocess.run([str(hook)], cwd=self.other, env=self.env, capture_output=True, text=True)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
(self.vault / "03_resources/new.md").write_text("# new\n")
proc = subprocess.run([str(hook)], cwd=self.other, env=self.env, capture_output=True, text=True)
self.assertNotEqual(proc.returncode, 0)
self.assertFalse((self.framework / "index/graph.json").exists())
def test_installer_preserves_unmanaged_hooks_and_reinstalls_managed_hook(self):
hook = self.vault / ".git/hooks/pre-commit"
custom = "#!/bin/sh\n# user's hook\nexit 0\n"
hook.write_text(custom)
proc = self.run_script("install-hooks.py", "--vault-root", self.vault)
self.assertNotEqual(proc.returncode, 0)
self.assertIn("unmanaged hook", proc.stderr)
self.assertEqual(hook.read_text(), custom)
hook.unlink()
(self.vault / "scripts").mkdir()
target = self.vault / "scripts/pre-commit"
target.write_text(custom)
hook.symlink_to("../../scripts/pre-commit")
for _ in range(2):
proc = self.run_script("install-hooks.py", "--vault-root", self.vault)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertFalse(hook.is_symlink())
self.assertEqual(target.read_text(), custom)
def test_installer_honors_worktree_and_custom_hooks_directory(self):
worktree = self.base / "worktree"
self.git(self.vault, "worktree", "add", "-qb", "worktree", str(worktree))
proc = self.run_script("install-hooks.py", "--vault-root", worktree)
self.assertEqual(proc.returncode, 0, proc.stderr)
hook = Path(self.git(worktree, "rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-commit"))
self.assertIn(str(worktree), hook.read_text())
custom = self.base / "custom hooks"
self.git(self.vault, "config", "core.hooksPath", str(custom))
proc = self.run_script("install-hooks.py", "--vault-root", self.vault)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn(str(self.vault), (custom / "pre-commit").read_text())
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -65,9 +65,9 @@ class TestValidateExitCode(unittest.TestCase):
self.root = Path(self._tmp.name).resolve() / "vault"
(self.root / "scripts").mkdir(parents=True)
(self.root / "03_resources").mkdir()
(self.root / "scripts" / "generate-graph.py").write_text(
(SCRIPTS / "generate-graph.py").read_text(encoding="utf-8"),
encoding="utf-8",
for name in ("generate-graph.py", "vault_root.py"):
(self.root / "scripts" / name).write_text(
(SCRIPTS / name).read_text(encoding="utf-8"), encoding="utf-8",
)
def tearDown(self) -> None: