feat: mirror tag-vocabulary summary and note-type validation

Ports two changes from the knowledge-base vault this skeleton is extracted
from, keeping the shared scripts byte-identical across the pair:

- `graph_summary` reports `by_tag` and `tag_count`, sized by `topTags`, and
  drops the `by_type`/`by_status` breakdowns. An MCP-only agent could not
  otherwise discover the tag spellings that `graph_search_nodes` matches
  verbatim.
- `generate-graph.py --validate` now enforces a controlled vocabulary for a
  note's `type`, which is where the drift signal `by_type` used to give now
  lives. `AGENT_QUERY_GUIDE.md`, both skills and the-knowing-one quote the
  enforced enum.

`tests/test_generate_graph.py` is new; `make test` is 50 tests. Regenerating
the graph also drops a stale `GLOBAL_AGENTS` node for a file this skeleton
does not carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manuel Ganter
2026-09-14 11:37:02 +02:00
co-authored by Claude Opus 5
parent 638cbe6b39
commit 1cb6369282
10 changed files with 316 additions and 52 deletions
+9 -3
View File
@@ -23,7 +23,9 @@ Schema:
- `title` (string): note title
- `path` (string): vault-relative markdown path
- `repo` (string): repo scope (`tenant-controller`, `controller-util`, `global`, ...)
- `type` (string): note classification (`guideline`, `policy`, `project`, etc.)
- `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
- `status` (string): lifecycle state (`active`, `draft`, `deprecated`)
- `tags` (string array): topic tags
- `last_reviewed` (string): `YYYY-MM-DD`
@@ -64,6 +66,10 @@ Examples with `jq`:
# list repos
jq -r '.nodes[].repo' index/graph.json | sort -u
# list the tag vocabulary, most frequent first (tags are matched verbatim)
jq -r '[.nodes[].tags[]?] | group_by(.) | map({tag: .[0], count: length})
| sort_by(-.count)[] | "\(.count)\t\(.tag)"' index/graph.json
# list top linked notes
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json
@@ -96,8 +102,8 @@ For `jg`, use the same filters if your local `jg` is jq-compatible.
Validation:
```bash
# validate: non-zero exit on unresolved links, edge types outside the
# vocabulary, OR any remaining legacy [[wiki links]]
# 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
# regenerate graph and validate
+11 -6
View File
@@ -26,7 +26,8 @@ through MCP.
## MCP tools
- `graph_summary` — overview of the graph: counts, types, tags, central nodes.
- `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.
- `graph_neighbors` — outbound + inbound links for a node id, each with its edge
`type` + `annotation` (traverse the graph).
@@ -56,15 +57,18 @@ README.md vault hub
```
Every note carries taxonomy frontmatter: `id`, `title`, `type`
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`), `status`
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
`generate-graph.py --validate`), `status`
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes link
with typed markdown links — `[text](note-id){"type":"…"}` — resolved by `id`.
Legacy `[[wiki links]]` are rejected by the graph validator and must not be written.
## 1. Orient
Start broad with `graph_summary` to see node counts, types, tags, and the
most-central notes. Then `read_note` the entry points:
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.
@@ -80,8 +84,9 @@ Use `graph_search_nodes` to locate notes:
- by **repo** — every note tied to a repo (e.g. `tenant-controller`).
- by **type** — `policy`, `decision`, `feature`, `guideline`, `project`, ...
- by **tag** — e.g. `kcp`.
- by **text** — when tag/type/id unknown, full-text search the notes.
- by **tag** — e.g. `kcp`. 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.
Use `graph_summary` to spot central (most-linked) notes worth reading early.
+1 -1
View File
@@ -25,7 +25,7 @@ already captured into `raw/` (a PDF, an image) at the absolute path
## MCP tools
Read:
- `graph_summary` — counts, types, tags, 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_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`.
- `read_note` — the Markdown of a note (by `id` or vault-relative `path`). On a
+1 -13
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"generated_at": "2026-09-11T07:34:35.807809+00:00",
"generated_at": "2026-09-14T09:33:34.687166+00:00",
"nodes": [
{
"id": ".claude/agents/the-knowing-one",
@@ -50,18 +50,6 @@
"outbound_links": [],
"inbound_count": 0
},
{
"id": "GLOBAL_AGENTS",
"title": "GLOBAL_AGENTS.md",
"path": "GLOBAL_AGENTS.md",
"repo": "global",
"type": "note",
"status": "active",
"tags": [],
"last_reviewed": "",
"outbound_links": [],
"inbound_count": 0
},
{
"id": "agents/README",
"title": "Agents",
+51 -2
View File
@@ -1,4 +1,13 @@
#!/usr/bin/env python3
"""Build `index/graph.json` from the vault's Markdown, and validate it.
The graph is the derived navigation layer agents query instead of walking the
tree: one node per note (frontmatter plus link counts) and one edge per typed
markdown link. `--validate` is the gate that keeps it trustworthy — it fails on
links that resolve to nothing, on edge relationship types or note `type` values
outside their controlled vocabularies, and on legacy `[[wiki links]]`.
"""
import argparse
import json
import re
@@ -36,6 +45,24 @@ RELATION_TYPES = {
}
# Strict controlled vocabulary for a note's frontmatter `type`. A note without
# one is a "note"; --validate fails on any value outside this set, which is what
# keeps the facet meaningful as the vault grows.
NOTE_TYPES = {
"hub",
"project",
"area",
"guideline",
"policy",
"feature",
"roadmap",
"decision",
"note",
"meta",
"reference",
}
@dataclass
class LinkRef:
target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL)
@@ -402,7 +429,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--validate",
action="store_true",
help="validate links: unresolved targets, edge relationship types, "
help="validate the graph: unresolved targets, edge relationship types, "
"note types, "
"and legacy [[wiki links]] (all rejected)",
)
parser.add_argument(
@@ -453,6 +481,25 @@ def print_invalid_types(invalid: List[Dict[str, object]]) -> None:
print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}")
def find_invalid_node_types(graph: Dict[str, object]) -> List[Dict[str, object]]:
return [
n
for n in graph.get("nodes", []) # type: ignore[union-attr]
if n.get("type") not in NOTE_TYPES
]
def print_invalid_node_types(invalid: List[Dict[str, object]]) -> None:
if not invalid:
print("Validation passed: all node types are in the controlled vocabulary")
return
print(f"Validation failed: {len(invalid)} note(s) with unknown type")
print(f" allowed: {', '.join(sorted(NOTE_TYPES))}")
for n in invalid:
print(f"- {n.get('id')} ({n.get('path')}): type={n.get('type')!r}")
def main() -> None:
args = parse_args()
root = Path(__file__).resolve().parents[1]
@@ -490,8 +537,10 @@ def main() -> None:
print_unresolved(unresolved)
invalid = find_invalid_types(graph)
print_invalid_types(invalid)
invalid_nodes = find_invalid_node_types(graph)
print_invalid_node_types(invalid_nodes)
print_wiki_links(wiki_uses)
if unresolved or invalid or wiki_uses:
if unresolved or invalid or invalid_nodes or wiki_uses:
sys.exit(1)
+40 -18
View File
@@ -25,6 +25,7 @@ import subprocess
import sys
import urllib.error
import urllib.request
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
@@ -196,6 +197,11 @@ _WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
_DOWNLOAD_TIMEOUT_SECONDS = 60
# graph_summary defaults: enough most-linked notes to spot the hubs, and enough
# tags to cover the vocabulary an agent actually filters on.
_DEFAULT_TOP_LINKED = 10
_DEFAULT_TOP_TAGS = 50
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
"""Turn a vault-relative path into an absolute path inside the repo root.
@@ -245,12 +251,31 @@ def _tool_definitions() -> List[Dict[str, Any]]:
return [
{
"name": "graph_summary",
"description": "Return aggregate counts from index/graph.json.",
"description": (
"Orientation for a cold start: node and edge counts, the repo "
"scopes, the most-linked notes, and the tag vocabulary as "
"`by_tag` (most frequent first) plus `tag_count`, the number of "
"distinct tags. Read `by_tag` before filtering "
"`graph_search_nodes` by `tag` \u2014 that filter matches a tag "
"verbatim, so a guessed spelling returns nothing."
),
"inputSchema": {
"type": "object",
"properties": {
"topLinked": {"type": "integer", "minimum": 1, "maximum": 50}
"topLinked": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "Most-linked notes to return (default 10).",
},
"topTags": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Tags to return, most frequent first (default 50).",
},
},
"additionalProperties": False,
},
},
{
@@ -453,21 +478,16 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
graph = _load_graph(state)
nodes = graph.get("nodes", [])
edges = graph.get("edges", [])
top_n = int(args.get("topLinked", 10))
top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
by_repo: Dict[str, int] = {}
by_type: Dict[str, int] = {}
by_status: Dict[str, int] = {}
for node in nodes:
by_repo[str(node.get("repo", "unknown"))] = (
by_repo.get(str(node.get("repo", "unknown")), 0) + 1
)
by_type[str(node.get("type", "unknown"))] = (
by_type.get(str(node.get("type", "unknown")), 0) + 1
)
by_status[str(node.get("status", "unknown"))] = (
by_status.get(str(node.get("status", "unknown")), 0) + 1
by_repo: Counter[str] = Counter(str(node.get("repo", "unknown")) for node in nodes)
tag_counts: Counter[str] = Counter(
str(tag) for node in nodes for tag in (node.get("tags") or [])
)
# Frequent tags first, alphabetical within a tie, so a truncated list is
# both the most useful slice and stable across calls.
ranked_tags = sorted(tag_counts.items(), key=lambda item: (-item[1], item[0]))
sorted_nodes = sorted(
nodes,
@@ -479,9 +499,11 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
"generated_at": graph.get("generated_at"),
"node_count": len(nodes),
"edge_count": len(edges),
"by_repo": by_repo,
"by_type": by_type,
"by_status": by_status,
"by_repo": dict(by_repo),
"tag_count": len(tag_counts),
"by_tag": [
{"tag": tag, "count": count} for tag, count in ranked_tags[:top_tags]
],
"top_linked": [
{
"id": node.get("id"),
@@ -229,7 +229,8 @@ 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|guideline|policy|feature|roadmap|decision|note|meta`) — use `note`;
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`),
which `generate-graph.py --validate` 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)`.
+2 -1
View File
@@ -29,7 +29,8 @@ README.md vault hub; AGENT_QUERY_GUIDE.md graph query refere
```
Every note carries taxonomy frontmatter: `id`, `title`, `type`
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`), `status`
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
`generate-graph.py --validate`), `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`.
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Tests for the graph generator's validation gate.
`--validate` is the only thing standing between a typo in a note's frontmatter
and a vault whose facets quietly stop meaning anything, so these tests pin the
controlled vocabularies it enforces: the edge relationship types and the node
`type` enum. Each filesystem test builds a throwaway vault, never the real one.
"""
from __future__ import annotations
import importlib
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
gen = importlib.import_module("generate-graph")
def _node(node_id: str, note_type: str) -> dict:
return {"id": node_id, "path": f"{node_id}.md", "type": note_type}
class TestNoteTypeVocabulary(unittest.TestCase):
"""The node `type` enum is the drift signal; it has to be enforced."""
def test_covers_every_type_the_taxonomy_documents(self) -> None:
self.assertLessEqual(
{
"hub",
"project",
"area",
"guideline",
"policy",
"feature",
"roadmap",
"decision",
"note",
"meta",
"reference",
},
gen.NOTE_TYPES,
)
def test_accepts_a_graph_whose_nodes_are_all_in_the_vocabulary(self) -> None:
graph = {"nodes": [_node("a", "note"), _node("b", "area")], "edges": []}
self.assertEqual(gen.find_invalid_node_types(graph), [])
def test_reports_a_node_whose_type_is_outside_the_vocabulary(self) -> None:
graph = {"nodes": [_node("a", "note"), _node("b", "refrence")], "edges": []}
invalid = gen.find_invalid_node_types(graph)
self.assertEqual([n["id"] for n in invalid], ["b"])
class TestValidateExitCode(unittest.TestCase):
"""--validate must fail the run, not just print, on an unknown node type."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
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",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def write_note(self, name: str, note_type: str) -> None:
(self.root / "03_resources" / name).write_text(
f"---\nid: {name[:-3]}\ntitle: {name}\ntype: {note_type}\n---\n\n# {name}\n",
encoding="utf-8",
)
def validate(self) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "scripts/generate-graph.py", "--validate"],
cwd=self.root,
capture_output=True,
text=True,
)
def test_passes_on_a_vault_of_known_types(self) -> None:
self.write_note("good.md", "decision")
proc = self.validate()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("all node types are in the controlled vocabulary", proc.stdout)
def test_fails_and_names_the_offending_note(self) -> None:
self.write_note("good.md", "decision")
self.write_note("bad.md", "descision")
proc = self.validate()
self.assertEqual(proc.returncode, 1)
self.assertIn("descision", proc.stdout)
self.assertIn("bad", proc.stdout)
if __name__ == "__main__":
unittest.main()
+92 -6
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Tests for the knowledge-base MCP server's write and git tools.
"""Tests for the knowledge-base MCP server's query, write and git tools.
Covers the tools an ingesting agent needs when the MCP server is its only way
into the vault: creating notes, editing notes, capturing raw bytes (local files
and downloads), committing staged paths, and pushing. Each test drives the
server through `_call_tool` against a throwaway repository so no test touches
the real vault.
Covers the tools an agent needs when the MCP server is its only way into the
vault: orienting with graph_summary, creating notes, editing notes, capturing
raw bytes (local files and downloads), committing staged paths, and pushing.
Each test drives the server through `_call_tool` against a throwaway repository
so no test touches the real vault.
"""
from __future__ import annotations
@@ -430,3 +430,89 @@ class TestReadNoteBinary(ToolTestCase):
if __name__ == "__main__":
unittest.main()
class TestGraphSummary(ToolTestCase):
"""graph_summary is the agent's vocabulary lookup, not a stats dump.
Tags are the vault's only high-cardinality facet and the `tag` filter of
graph_search_nodes matches them verbatim, so the summary has to name them.
"""
def write_graph(self, nodes: list) -> None:
self.state.graph_path.parent.mkdir(parents=True, exist_ok=True)
self.state.graph_path.write_text(
json.dumps(
{
"version": 1,
"generated_at": "2026-09-14T00:00:00Z",
"nodes": nodes,
"edges": [],
}
),
encoding="utf-8",
)
def node(self, node_id: str, **fields) -> dict:
base = {
"id": node_id,
"title": node_id,
"path": f"03_resources/{node_id}.md",
"repo": "global",
"type": "note",
"status": "active",
"tags": [],
"inbound_count": 0,
}
base.update(fields)
return base
def test_counts_tags_and_orders_them_by_frequency(self) -> None:
self.write_graph(
[
self.node("a", tags=["kcp", "flux"]),
self.node("b", tags=["kcp"]),
self.node("c", tags=["kcp", "helm"]),
]
)
payload = self.structured("graph_summary")
self.assertEqual(
payload["by_tag"],
[
{"tag": "kcp", "count": 3},
{"tag": "flux", "count": 1},
{"tag": "helm", "count": 1},
],
)
def test_reports_the_distinct_tag_total_even_when_truncated(self) -> None:
self.write_graph(
[self.node(f"n{i}", tags=[f"tag-{i}"]) for i in range(10)]
)
payload = self.structured("graph_summary", topTags=3)
self.assertEqual(len(payload["by_tag"]), 3)
self.assertEqual(payload["tag_count"], 10)
def test_drops_the_type_and_status_facets(self) -> None:
self.write_graph([self.node("a"), self.node("b", status="deprecated")])
payload = self.structured("graph_summary")
self.assertNotIn("by_type", payload)
self.assertNotIn("by_status", payload)
def test_keeps_repo_counts_and_most_linked_notes(self) -> None:
self.write_graph(
[
self.node("hub", repo="global", inbound_count=9),
self.node("leaf", repo="kunnus", inbound_count=1),
]
)
payload = self.structured("graph_summary", topLinked=1)
self.assertEqual(payload["by_repo"], {"global": 1, "kunnus": 1})
self.assertEqual([n["id"] for n in payload["top_linked"]], ["hub"])
self.assertEqual(payload["node_count"], 2)
def test_advertises_the_top_tags_argument(self) -> None:
tool = next(
t for t in mcp._tool_definitions() if t["name"] == "graph_summary"
)
self.assertIn("topTags", tool["inputSchema"]["properties"])