Support external vault roots across tooling
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.claude/settings.local.json
|
.claude/
|
||||||
|
.idea/
|
||||||
|
.codex/
|
||||||
+21
-9
@@ -9,7 +9,19 @@ last_reviewed: 2026-06-16
|
|||||||
|
|
||||||
# Agent Query Guide
|
# 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:
|
Schema:
|
||||||
|
|
||||||
@@ -22,7 +34,7 @@ Schema:
|
|||||||
- `id` (string): stable note identifier (usually from frontmatter)
|
- `id` (string): stable note identifier (usually from frontmatter)
|
||||||
- `title` (string): note title
|
- `title` (string): note title
|
||||||
- `path` (string): vault-relative markdown path
|
- `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 —
|
- `type` (string): note classification from a controlled vocabulary —
|
||||||
`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`;
|
`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`;
|
||||||
absent frontmatter means `note`, and `--validate` fails on anything else
|
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`.
|
see-also, source`.
|
||||||
- Legacy `[[wiki links]]` are **no longer accepted**: `generate-graph.py --validate`
|
- Legacy `[[wiki links]]` are **no longer accepted**: `generate-graph.py --validate`
|
||||||
fails on any that remain. Locate and convert them with
|
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`:
|
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
|
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json
|
||||||
|
|
||||||
# notes in one repo
|
# 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)
|
# all projects (PARA category 1)
|
||||||
jq -r '.nodes[] | select(.type=="project") | "\(.id)\t\(.path)"' index/graph.json
|
jq -r '.nodes[] | select(.type=="project") | "\(.id)\t\(.path)"' index/graph.json
|
||||||
|
|
||||||
# outgoing links from one note
|
# 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
|
jq -r --arg id "$NOTE_ID" '.nodes[] | select(.id==$id) | .outbound_links[]?' index/graph.json
|
||||||
|
|
||||||
# incoming links to one note
|
# 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
|
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
|
# outbound edges of a note WITH relationship type + annotation
|
||||||
@@ -104,11 +116,11 @@ Validation:
|
|||||||
```bash
|
```bash
|
||||||
# validate: non-zero exit on unresolved links, edge types or note types
|
# validate: non-zero exit on unresolved links, edge types or note types
|
||||||
# outside their vocabularies, OR any remaining legacy [[wiki links]]
|
# 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
|
# 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
|
# 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
|
PYTHON ?= python3
|
||||||
|
VAULT_ROOT ?= $(if $(KNOWLEDGE_BASE_ROOT),$(KNOWLEDGE_BASE_ROOT),$(CURDIR))
|
||||||
CLAUDE_HOME ?= $(HOME)/.claude
|
CLAUDE_HOME ?= $(HOME)/.claude
|
||||||
MCP_NAME ?= knowledge-database
|
MCP_NAME ?= knowledge-database
|
||||||
|
|
||||||
@@ -8,18 +10,17 @@ test:
|
|||||||
$(PYTHON) -m unittest discover -s tests
|
$(PYTHON) -m unittest discover -s tests
|
||||||
|
|
||||||
generate-graph:
|
generate-graph:
|
||||||
$(PYTHON) scripts/generate-graph.py
|
$(PYTHON) scripts/generate-graph.py --vault-root "$(VAULT_ROOT)"
|
||||||
|
|
||||||
validate-graph:
|
validate-graph:
|
||||||
$(PYTHON) scripts/generate-graph.py --validate
|
$(PYTHON) scripts/generate-graph.py --vault-root "$(VAULT_ROOT)" --validate
|
||||||
|
|
||||||
graph: generate-graph validate-graph
|
graph: generate-graph validate-graph
|
||||||
|
|
||||||
install-hooks:
|
install-hooks:
|
||||||
chmod +x scripts/pre-commit
|
$(PYTHON) scripts/install-hooks.py --vault-root "$(VAULT_ROOT)"
|
||||||
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
|
|
||||||
|
|
||||||
# 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.
|
# (~/.claude/skills, ~/.claude/agents) so they are invocable from any repo.
|
||||||
install-global-claude:
|
install-global-claude:
|
||||||
mkdir -p "$(CLAUDE_HOME)/skills" "$(CLAUDE_HOME)/agents"
|
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.
|
# so it is available from any repo). Re-run to update; removes stale entry first.
|
||||||
install-mcp-claude:
|
install-mcp-claude:
|
||||||
-claude mcp remove --scope user "$(MCP_NAME)" 2>/dev/null
|
-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)"
|
@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
|
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
|
## Generation
|
||||||
- Run `make graph`
|
- Run `make graph`
|
||||||
- Output is written to `index/graph.json`
|
- Output is written to `index/graph.json`
|
||||||
|
|||||||
+18
-20
@@ -26,6 +26,9 @@ through MCP.
|
|||||||
|
|
||||||
## MCP tools
|
## 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
|
- `graph_summary` — overview of the graph: counts, repo scopes, the tag
|
||||||
vocabulary (`by_tag`, most frequent first) and central nodes.
|
vocabulary (`by_tag`, most frequent first) and central nodes.
|
||||||
- `graph_search_nodes` — find nodes by tag / type / repo / id / text.
|
- `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:
|
Given a **topic or task**, return a complete, cited briefing:
|
||||||
|
|
||||||
1. **Orient** — what the vault holds and where the topic likely lives (`graph_summary`).
|
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.
|
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.
|
4. **Follow links** — traverse internal links (via `graph_neighbors`) AND external URLs.
|
||||||
5. **Synthesize** — one briefing, every claim cited to a note path/id.
|
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)
|
## Vault layout (PARA)
|
||||||
|
|
||||||
```
|
```
|
||||||
01_projects/ active efforts with a goal and an end
|
01_projects/ active efforts with a goal and an end
|
||||||
02_areas/ ongoing standards: policies, value-stream areas, decisions (ADRs)
|
02_areas/ ongoing responsibilities and standards
|
||||||
03_resources/meta/ taxonomy + note templates
|
03_resources/ reference material
|
||||||
03_resources/repos/<repo>/ per-repo info.md (+ decisions/)
|
04_archives/ inactive items
|
||||||
03_resources/repos/repo-landscape.md index of documented repos
|
|
||||||
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`
|
Every note carries taxonomy frontmatter: `id`, `title`, `type`
|
||||||
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
|
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
|
||||||
`generate-graph.py --validate`), `status`
|
`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
|
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
|
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
|
tag spellings are visible — read it before filtering by `tag`, which matches
|
||||||
verbatim. Then `read_note` the entry points:
|
verbatim. Find hubs with `graph_search_nodes(type="hub")` and read relevant
|
||||||
|
ones with `read_note`. Discover policies and repo scopes through the graph;
|
||||||
- vault hub (`README.md`) — entry points.
|
do not assume a root README or any particular taxonomy subfolders exist.
|
||||||
- `03_resources/repos/repo-landscape.md` — index of documented repos, grouped.
|
If no relevant node exists, say the topic is not yet documented.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 2. Find — query the graph
|
## 2. Find — query the graph
|
||||||
|
|
||||||
Use `graph_search_nodes` to locate notes:
|
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 **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
|
- 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.
|
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.
|
- Answer the topic/task directly from what the notes say.
|
||||||
- Cite every claim by vault-relative `path` (e.g.
|
- 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
|
- Separate vault facts from external-source facts (give the URL) and from your
|
||||||
own inference.
|
own inference.
|
||||||
- Flag `status: draft`/`deprecated` notes. If the vault does not cover part of
|
- 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
|
## MCP tools
|
||||||
|
|
||||||
Read:
|
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_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_search_nodes` — find notes by tag / type / repo / id / free text.
|
||||||
- `graph_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`.
|
- `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,
|
- `edit_note(path, old_string, new_string, replace_all?)` — exact-string edit,
|
||||||
for backlinks into notes that already exist.
|
for backlinks into notes that already exist.
|
||||||
|
|
||||||
Publish:
|
|
||||||
- `refresh_graph(validate=true)` — regenerate and validate `index/graph.json`.
|
- `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_commit(message, paths[])` — stage the named paths and commit.
|
||||||
- `git_push(remote?, branch?)` — push to `origin` / current branch.
|
- `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)
|
Skill(knowledge-base-ingest-resource)
|
||||||
```
|
```
|
||||||
|
|
||||||
The skill is the source of truth for the procedure. Translate each of its shell
|
Then call `read_instructions` and read both documents before any vault mutation.
|
||||||
steps into the MCP equivalent:
|
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 |
|
The skill is the source of truth for the procedure. Follow its MCP calls directly;
|
||||||
|-------------------------------------|--------------------------------------------------|
|
do not translate missing operations into shell commands.
|
||||||
| 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=[…])` |
|
|
||||||
|
|
||||||
## Operating rules
|
## 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
|
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`
|
report what is missing. Verify the capture landed: the result carries `bytes`
|
||||||
and `sha256`.
|
and `sha256`.
|
||||||
- **YouTube is still out of reach.** Transcripts need `yt-dlp` or
|
- **YouTube transcripts must be supplied.** Capture a supplied transcript with
|
||||||
`youtube-transcript-api`, which are shell tools you do not have. Capture the
|
`capture_raw` or `write_note`. If none is supplied, preserve the source URL and
|
||||||
URL into `raw/source.url`, then hand the video to **the-collector** rather than
|
report the missing transcript before writing content notes. Do not run shell
|
||||||
inventing transcript text.
|
extractors or invent transcript text.
|
||||||
- **Link, don't duplicate.** Search the graph before writing a topic note. If the
|
- **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.
|
vault already covers the topic, link it instead of creating a second note.
|
||||||
- **Typed markdown links only.** `[title](note-id){"type":"…","link-info":"…"}`.
|
- **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
|
- **Name every path you commit.** `git_commit` takes an explicit list; wildcard
|
||||||
staging is rejected by the server. Include the resource folder, every
|
staging is rejected by the server. Include the resource folder, every
|
||||||
backlinked note, and `index/graph.json`.
|
backlinked note, and `index/graph.json`.
|
||||||
- **Push straight to `main`.** This vault takes ingests as direct commits; no
|
- **Follow the caller's publishing instructions.** Do not commit or push unless
|
||||||
branch, no PR. Commit with Conventional Commits (`feat: ingest <resource>`),
|
explicitly authorized. Use the selected vault's branch and review policy;
|
||||||
then `git_push`. If the caller says "don't push", stop after the commit.
|
do not assume a branch name or a direct-push workflow. Without publishing
|
||||||
|
authorization, report the changed paths and validation result.
|
||||||
|
|
||||||
## Report
|
## Report
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
from vault_root import parse_vault_args
|
||||||
|
|
||||||
|
|
||||||
# Obsidian-style wiki link: [[target#anchor|alias]]
|
# Obsidian-style wiki link: [[target#anchor|alias]]
|
||||||
WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
||||||
@@ -438,7 +440,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="write index/graph.json (default unless --validate is used alone)",
|
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:
|
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:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = args.vault_root
|
||||||
output = root / "index" / "graph.json"
|
output = root / "index" / "graph.json"
|
||||||
should_write = args.write or not args.validate
|
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 pathlib import Path
|
||||||
from typing import Dict, List
|
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).
|
# Reuse the parser/resolver from generate-graph.py (hyphenated name -> load by path).
|
||||||
_GG_PATH = Path(__file__).resolve().with_name("generate-graph.py")
|
_GG_PATH = Path(__file__).resolve().with_name("generate-graph.py")
|
||||||
_spec = importlib.util.spec_from_file_location("generate_graph", _GG_PATH)
|
_spec = importlib.util.spec_from_file_location("generate_graph", _GG_PATH)
|
||||||
@@ -102,9 +104,9 @@ def main() -> None:
|
|||||||
default="grouped",
|
default="grouped",
|
||||||
help="output format (default: grouped text)",
|
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)
|
rows = collect(root)
|
||||||
if args.format == "tsv":
|
if args.format == "tsv":
|
||||||
print_tsv(rows)
|
print_tsv(rows)
|
||||||
|
|||||||
+48
-5
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
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*
|
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
|
(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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
@@ -30,6 +32,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from vault_root import FRAMEWORK_ROOT, parse_vault_args
|
||||||
|
|
||||||
SERVER_NAME = "knowledge-base-mcp"
|
SERVER_NAME = "knowledge-base-mcp"
|
||||||
SERVER_VERSION = "0.1.0"
|
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:
|
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(
|
return subprocess.run(
|
||||||
["git", *args],
|
["git", *args],
|
||||||
cwd=state.root,
|
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]]:
|
def _tool_definitions() -> List[Dict[str, Any]]:
|
||||||
return [
|
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",
|
"name": "graph_summary",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -474,6 +496,15 @@ def _text_result(text: str, structured: Dict[str, Any] | None = None) -> Dict[st
|
|||||||
return result
|
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]:
|
def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
graph = _load_graph(state)
|
graph = _load_graph(state)
|
||||||
nodes = graph.get("nodes", [])
|
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]:
|
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)):
|
if bool(args.get("validate", False)):
|
||||||
command.append("--validate")
|
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]:
|
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":
|
if name == "graph_summary":
|
||||||
return _tool_graph_summary(state, arguments)
|
return _tool_graph_summary(state, arguments)
|
||||||
if name == "graph_search_nodes":
|
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]:
|
def _list_resources(state: AppState) -> Dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"uri": "repo://graph",
|
"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]:
|
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:
|
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")
|
state = AppState(root=root, graph_path=root / "index" / "graph.json")
|
||||||
|
|
||||||
reader = _StdinReader(sys.stdin.buffer)
|
reader = _StdinReader(sys.stdin.buffer)
|
||||||
|
|||||||
+7
-3
@@ -1,11 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Generate and validate the selected vault graph before accepting a commit.
|
||||||
set -euo pipefail
|
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..."
|
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] 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
|
exit 1
|
||||||
fi
|
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
|
original bytes under `raw/`, decompose it into topics, and link those topics into
|
||||||
the knowledge graph. Follow every step.
|
the knowledge graph. Follow every step.
|
||||||
|
|
||||||
Obey `AGENTS.md` and `02_areas/ways-of-working.md` (read them if not already
|
Before ingesting, call `read_instructions` on `knowledge-database` and read both
|
||||||
loaded this session; if `ways-of-working.md` does not exist yet, skip it).
|
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
|
The output is a `type: note` reference note at
|
||||||
`03_resources/<resource-name>/index.md`, one `type: note` **topic note** per
|
`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`,
|
title. Keep it descriptive and unique (e.g. `cyclonedx-sbom-spec`,
|
||||||
`oauth2-rfc6749`).
|
`oauth2-rfc6749`).
|
||||||
|
|
||||||
```bash
|
Check for an existing resource with `graph_search_nodes(query="<resource-name>")`
|
||||||
ls 03_resources/ # check the slug is not already taken
|
and `read_note(path="03_resources/<resource-name>/index.md")`. The capture and
|
||||||
mkdir -p 03_resources/<resource-name>/raw
|
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
|
If the slug already exists, either you are re-ingesting (update in place, tell the
|
||||||
user) or pick a more specific slug.
|
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.
|
Always preserve the source bytes untouched. Pick the branch that matches step 1.
|
||||||
|
|
||||||
**Local file** — copy verbatim, keep the original name:
|
**Local file** — use `capture_raw` with the supplied `source_path` and a
|
||||||
```bash
|
vault-relative `path` such as `03_resources/<resource-name>/raw/<filename>`.
|
||||||
cp "<source-path>" "03_resources/<resource-name>/raw/"
|
This preserves the original bytes and filename.
|
||||||
```
|
|
||||||
|
|
||||||
**PDF / binary at a URL** — download verbatim:
|
**PDF / binary at a URL** — use `capture_raw(url="<url>", path=".../raw/<filename>")`.
|
||||||
```bash
|
Check the returned byte count and SHA-256.
|
||||||
curl -fsSL "<url>" -o "03_resources/<resource-name>/raw/<filename>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Webpage / link** — record the URL and save the readable content. Use `WebFetch`
|
**Webpage / link** — save the URL using
|
||||||
to retrieve it (request markdown), then write the result and a `source.url`:
|
`write_note(path=".../raw/source.url", content="<url>\n")`. Capture the original
|
||||||
```bash
|
page with `capture_raw(url="<url>", path=".../raw/page.html")`. If the invoking
|
||||||
printf '%s\n' "<url>" > "03_resources/<resource-name>/raw/source.url"
|
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=...)`.
|
||||||
Save the fetched body to `raw/page.md` (write the `WebFetch` output to that file).
|
|
||||||
|
|
||||||
**YouTube video** — pull the transcript into `raw/`. Record the URL first, then
|
**YouTube video** — record the URL with `write_note`. If a transcript is supplied,
|
||||||
try, in order:
|
capture its file with `capture_raw(source_path=..., path=".../raw/transcript.txt")`
|
||||||
```bash
|
or preserve supplied text with `write_note`. These MCP tools do not extract
|
||||||
printf '%s\n' "<url>" > "03_resources/<resource-name>/raw/source.url"
|
YouTube transcripts. If none is supplied, report the missing transcript and stop
|
||||||
|
before writing content notes. Never invent transcript text or run shell extractors.
|
||||||
# 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.
|
|
||||||
|
|
||||||
## 4. Read the captured content
|
## 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
|
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
|
matches the bytes we preserved. For long content, read enough to identify every
|
||||||
distinct topic, not just the intro.
|
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,
|
concepts, standards, formats, tools, or entities it discusses. For each topic,
|
||||||
decide whether the vault already knows about it, and whether it points outward.
|
decide whether the vault already knows about it, and whether it points outward.
|
||||||
|
|
||||||
For every candidate topic, search the vault for a matching note:
|
For every candidate topic, use `graph_search_nodes(query="<topic>")` and tag
|
||||||
|
filters from `graph_summary`, then traverse `graph_neighbors` and `read_note`
|
||||||
```bash
|
promising matches. Confirm they cover the same concept before linking.
|
||||||
# free-text over the graph (id / title / path / tags)
|
The query searches metadata, not note bodies; read candidate notes when needed.
|
||||||
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.
|
|
||||||
|
|
||||||
Classify each topic:
|
Classify each topic:
|
||||||
- **Internal** — the vault has a note for it → link with
|
- **Internal** — the vault has a note for it → link with
|
||||||
@@ -145,7 +129,7 @@ Classify each topic:
|
|||||||
|
|
||||||
## 6. Write a note per 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
|
`03_resources/<resource-name>/<topic-name>.md` (kebab-case slug). This is what
|
||||||
makes topics first-class, individually linkable graph nodes.
|
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
|
- Link each topic note back to its resource with
|
||||||
`[<resource title>](resource/<resource-name>){"type":"part-of"}`, and outward to
|
`[<resource title>](resource/<resource-name>){"type":"part-of"}`, and outward to
|
||||||
internal notes (same link form) and external sources (URLs).
|
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)
|
## 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.
|
raw source to the topic notes.
|
||||||
|
|
||||||
```md
|
```md
|
||||||
@@ -230,7 +214,7 @@ last_reviewed: <today, YYYY-MM-DD>
|
|||||||
- `ingested` and `last_reviewed` = today (the `currentDate` from session context).
|
- `ingested` and `last_reviewed` = today (the `currentDate` from session context).
|
||||||
- `type` must stay within the vault taxonomy enum
|
- `type` must stay within the vault taxonomy enum
|
||||||
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`),
|
(`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.
|
the `resource` tag is what marks it.
|
||||||
- Markdown link targets resolve by `id`, rel-path, title, or unique stem — always
|
- 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)`.
|
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.
|
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
|
under a `## Resources` or `## Related` section, creating it if absent. Add a
|
||||||
`{"type":"…","link-info":"…"}` brace where the relationship is more specific than
|
`{"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
|
- 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
|
lists them, and link the new note from there (create the hub if it does not
|
||||||
exist, with `type: hub`, `tags: [resource, hub]`).
|
exist, with `type: hub`, `tags: [resource, hub]`).
|
||||||
|
|
||||||
## 9. Regenerate and validate the graph
|
## 9. Regenerate and validate the graph
|
||||||
|
|
||||||
```bash
|
Use `refresh_graph(validate=true)`.
|
||||||
make graph
|
|
||||||
```
|
|
||||||
|
|
||||||
This regenerates `index/graph.json` and validates links and edge relationship
|
This regenerates `index/graph.json` and validates links and edge relationship
|
||||||
types. If validation reports unresolved links, fix the targets (typos, wrong `id`,
|
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)
|
## 10. Stage (do not commit unless asked)
|
||||||
|
|
||||||
Per `ways-of-working.md`, NEVER use `git add -A` or `git add .`. Stage each
|
Without explicit publishing authorization, leave the changes uncommitted and
|
||||||
touched path individually:
|
report the touched paths. The MCP interface has no stage-only tool.
|
||||||
|
|
||||||
```bash
|
When a commit is authorized, use `git_commit(message=..., paths=[...])` with an
|
||||||
git add 03_resources/<resource-name>/ # note + raw/
|
explicit list covering the resource folder, backlinked notes and `index/graph.json`.
|
||||||
git add index/graph.json
|
This tool stages those paths and commits them; do not use wildcard paths.
|
||||||
git add <any-existing-note-you-backlinked>.md
|
Use `git_push` only when pushing is explicitly authorized. Follow the caller's
|
||||||
```
|
branch and review policy.
|
||||||
|
|
||||||
Only commit or open a PR if the user explicitly asks. Changes go via small, clear
|
|
||||||
pull requests.
|
|
||||||
|
|
||||||
## 11. Report
|
## 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
|
index. Use this skill to answer questions FROM the vault and to surface the
|
||||||
exact notes that back the answer.
|
exact notes that back the answer.
|
||||||
|
|
||||||
Obey `AGENTS.md` (read them if not loaded this
|
Call `read_instructions` on `knowledge-database` to read the framework's
|
||||||
session). This skill is read-only — do not modify notes here.
|
`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)
|
## 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
|
03_resources/repos/repo-landscape.md index of documented repos
|
||||||
04_archives/ inactive items
|
04_archives/ inactive items
|
||||||
index/graph.json generated graph (nodes + edges)
|
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`
|
Every note carries taxonomy frontmatter: `id`, `title`, `type`
|
||||||
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
|
(`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
|
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes are
|
||||||
linked with typed markdown links — `[text](note-id){"type":"…"}` — that resolve
|
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
|
## 1. Orient
|
||||||
|
|
||||||
Start broad, then narrow:
|
Start broad, then narrow. Begin with `graph_summary`, then find hubs using
|
||||||
|
`graph_search_nodes(type="hub")` and read relevant ones with `read_note`.
|
||||||
- `README.md` — vault hub and entry points.
|
Discover repo scopes and tags from the graph rather than assuming a particular
|
||||||
- `03_resources/repos/repo-landscape.md` — index of every documented repo, grouped.
|
folder layout. An external data vault need not contain a root README or
|
||||||
- `02_areas/ways-of-working.md` — global engineering policy.
|
framework operational documents.
|
||||||
- `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).
|
|
||||||
|
|
||||||
## 2. Query the graph
|
## 2. Query the graph
|
||||||
|
|
||||||
`index/graph.json` is for navigation by tag / type / repo / links. Use `jq`.
|
Use the MCP tools directly:
|
||||||
Full schema and more examples are in `AGENT_QUERY_GUIDE.md`.
|
|
||||||
|
|
||||||
```bash
|
| Need | Tool |
|
||||||
# all documented repos
|
|------|------|
|
||||||
jq -r '.nodes[].repo' index/graph.json | sort -u
|
| 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
|
`query` does not search note bodies. Read candidate notes with `read_note` and
|
||||||
jq -r '.nodes[] | select(.repo=="tenant-controller") | "\(.id)\t\(.path)"' index/graph.json
|
follow their neighbors when metadata search is insufficient. Report any search
|
||||||
|
coverage limits; do not use filesystem or shell searches.
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Read the source notes
|
## 3. Read the source notes
|
||||||
|
|
||||||
The graph gives ids and paths; the ANSWER lives in the Markdown. Open the
|
The graph gives ids and paths; the ANSWER lives in the Markdown. Read matched
|
||||||
matched `.path` files and read them. Follow the typed markdown links
|
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
|
`[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
|
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
|
judge which links are worth following; links are the intended navigation, so
|
||||||
traverse them rather than guessing.
|
traverse them rather than guessing.
|
||||||
|
|
||||||
If the graph looks stale or a path is missing, you may regenerate read-only:
|
If the graph looks stale or a path is missing, use `refresh_graph`. This updates
|
||||||
`python3 scripts/generate-graph.py` (does not touch Markdown). Do not commit.
|
only the generated index; it does not touch Markdown. Do not commit.
|
||||||
|
|
||||||
## 4. Answer with citations
|
## 4. Answer with citations
|
||||||
|
|
||||||
- Answer the question directly from what the notes say.
|
- Answer the question directly from what the notes say.
|
||||||
- Cite the backing notes by vault-relative `path` (e.g.
|
- 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
|
- Distinguish vault facts from inference. If the vault does not cover it, say so
|
||||||
plainly — do not invent. Note `status: draft`/`deprecated` when relevant.
|
plainly — do not invent. Note `status: draft`/`deprecated` when relevant.
|
||||||
- For "what links to / relates to X" questions, report the inbound/outbound
|
- 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,10 +65,10 @@ class TestValidateExitCode(unittest.TestCase):
|
|||||||
self.root = Path(self._tmp.name).resolve() / "vault"
|
self.root = Path(self._tmp.name).resolve() / "vault"
|
||||||
(self.root / "scripts").mkdir(parents=True)
|
(self.root / "scripts").mkdir(parents=True)
|
||||||
(self.root / "03_resources").mkdir()
|
(self.root / "03_resources").mkdir()
|
||||||
(self.root / "scripts" / "generate-graph.py").write_text(
|
for name in ("generate-graph.py", "vault_root.py"):
|
||||||
(SCRIPTS / "generate-graph.py").read_text(encoding="utf-8"),
|
(self.root / "scripts" / name).write_text(
|
||||||
encoding="utf-8",
|
(SCRIPTS / name).read_text(encoding="utf-8"), encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|||||||
Reference in New Issue
Block a user