Support external vault roots across tooling
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/settings.local.json
|
||||
.claude/
|
||||
.idea/
|
||||
.codex/
|
||||
+21
-9
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user