From 293b582e971bc20758c03a6b2608dcddeb5cd861 Mon Sep 17 00:00:00 2001 From: Tobias Ganter Date: Sun, 20 Sep 2026 17:05:21 +0200 Subject: [PATCH] Support external vault roots across tooling --- .gitignore | 4 +- AGENT_QUERY_GUIDE.md | 30 ++- Makefile | 13 +- README.md | 48 ++++ agents/the-knowing-one.md | 38 ++- agents/the-scribe.md | 41 ++-- scripts/generate-graph.py | 6 +- scripts/install-hooks.py | 43 ++++ scripts/list-wiki-links.py | 6 +- scripts/mcp-server.py | 53 +++- scripts/pre-commit | 10 +- scripts/vault_root.py | 27 +++ .../knowledge-base-ingest-resource/SKILL.md | 133 +++++----- skills/knowledge-base-read/SKILL.md | 90 +++---- tests/test_external_vault.py | 227 ++++++++++++++++++ tests/test_generate_graph.py | 8 +- 16 files changed, 573 insertions(+), 204 deletions(-) create mode 100644 scripts/install-hooks.py create mode 100644 scripts/vault_root.py create mode 100644 tests/test_external_vault.py diff --git a/.gitignore b/.gitignore index dd7f95f..e69f577 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ *.pyc -.claude/settings.local.json +.claude/ +.idea/ +.codex/ \ No newline at end of file diff --git a/AGENT_QUERY_GUIDE.md b/AGENT_QUERY_GUIDE.md index 3224708..2e09bda 100644 --- a/AGENT_QUERY_GUIDE.md +++ b/AGENT_QUERY_GUIDE.md @@ -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=="") | "\(.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="" 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="" 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" ``` diff --git a/Makefile b/Makefile index 1ddbba5..843d197 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md index 0dfe31c..3ceabce 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/agents/the-knowing-one.md b/agents/the-knowing-one.md index bdf7669..02e9e38 100644 --- a/agents/the-knowing-one.md +++ b/agents/the-knowing-one.md @@ -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. @@ -47,15 +50,15 @@ Given a **topic or task**, return a complete, cited briefing: ## Vault layout (PARA) ``` -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// per-repo info.md (+ decisions/) -03_resources/repos/repo-landscape.md index of documented repos -04_archives/ inactive items -README.md vault hub +01_projects/ active efforts with a goal and an end +02_areas/ ongoing responsibilities and standards +03_resources/ reference material +04_archives/ inactive items ``` +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//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. ``). - 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//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 diff --git a/agents/the-scribe.md b/agents/the-scribe.md index 712d433..b583524 100644 --- a/agents/the-scribe.md +++ b/agents/the-scribe.md @@ -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//raw/source.url", …)` | -| save fetched page to `raw/page.md` | `WebFetch` → `write_note(".../raw/page.md", …)` | -| `cp "" raw/` | `capture_raw(path=".../raw/", source_path="")` | -| `curl -fsSL "" -o raw/` | `capture_raw(path=".../raw/", url="")` | -| read the captured binary | `Read()` | -| write the topic + index notes | `write_note` | -| backlink an existing note | `edit_note` | -| `make graph` | `refresh_graph(validate=true)` | -| `git add ` (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 `), - 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 diff --git a/scripts/generate-graph.py b/scripts/generate-graph.py index 3ed08b9..e278373 100644 --- a/scripts/generate-graph.py +++ b/scripts/generate-graph.py @@ -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 diff --git a/scripts/install-hooks.py b/scripts/install-hooks.py new file mode 100644 index 0000000..e04f9d6 --- /dev/null +++ b/scripts/install-hooks.py @@ -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() diff --git a/scripts/list-wiki-links.py b/scripts/list-wiki-links.py index b3f8066..a748618 100755 --- a/scripts/list-wiki-links.py +++ b/scripts/list-wiki-links.py @@ -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) diff --git a/scripts/mcp-server.py b/scripts/mcp-server.py index 53f51dd..b920bf6 100755 --- a/scripts/mcp-server.py +++ b/scripts/mcp-server.py @@ -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) diff --git a/scripts/pre-commit b/scripts/pre-commit index 58a0ca4..20d3ffb 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -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 diff --git a/scripts/vault_root.py b/scripts/vault_root.py new file mode 100644 index 0000000..9e44e4f --- /dev/null +++ b/scripts/vault_root.py @@ -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 diff --git a/skills/knowledge-base-ingest-resource/SKILL.md b/skills/knowledge-base-ingest-resource/SKILL.md index 02d28f1..dc84896 100644 --- a/skills/knowledge-base-ingest-resource/SKILL.md +++ b/skills/knowledge-base-ingest-resource/SKILL.md @@ -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//index.md`, one `type: note` **topic note** per @@ -47,10 +62,9 @@ Pick a short kebab-case `` 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//raw -``` +Check for an existing resource with `graph_search_nodes(query="")` +and `read_note(path="03_resources//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 "" "03_resources//raw/" -``` +**Local file** — use `capture_raw` with the supplied `source_path` and a +vault-relative `path` such as `03_resources//raw/`. +This preserves the original bytes and filename. -**PDF / binary at a URL** — download verbatim: -```bash -curl -fsSL "" -o "03_resources//raw/" -``` +**PDF / binary at a URL** — use `capture_raw(url="", path=".../raw/")`. +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' "" > "03_resources//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="\n")`. Capture the original +page with `capture_raw(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' "" > "03_resources//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//raw/%(title)s.%(ext)s" "" - -# 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=''; \ -open('03_resources//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//raw/")` 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 "" '.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 "" '.nodes[] | select(.tags[]? == $t) | "\(.id)\t\(.path)"' index/graph.json - -# full-text fallback across the PARA buckets -grep -rIl "" 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="")` 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//.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/){"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//index.md`. This is the hub that ties the +Use `write_note` for `03_resources//index.md`. This hub ties the raw source to the topic notes. ```md @@ -230,7 +214,7 @@ last_reviewed: - `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// # note + raw/ -git add index/graph.json -git add .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 diff --git a/skills/knowledge-base-read/SKILL.md b/skills/knowledge-base-read/SKILL.md index e76f273..304d91c 100644 --- a/skills/knowledge-base-read/SKILL.md +++ b/skills/knowledge-base-read/SKILL.md @@ -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//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="")` | +| Notes of a type | `graph_search_nodes(type="policy")` | +| Notes with a tag | `graph_search_nodes(tag="")` | +| Search ids, titles, paths and tags | `graph_search_nodes(query="")` | +| Incoming/outgoing links, relationship types and annotations | `graph_neighbors(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 "" 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="")` or `read_note(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//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 diff --git a/tests/test_external_vault.py b/tests/test_external_vault.py new file mode 100644 index 0000000..5c2feed --- /dev/null +++ b/tests/test_external_vault.py @@ -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() diff --git a/tests/test_generate_graph.py b/tests/test_generate_graph.py index 4914c9c..9fc8013 100644 --- a/tests/test_generate_graph.py +++ b/tests/test_generate_graph.py @@ -65,10 +65,10 @@ 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: self._tmp.cleanup()