diff --git a/AGENT_QUERY_GUIDE.md b/AGENT_QUERY_GUIDE.md index 9f54756..3224708 100644 --- a/AGENT_QUERY_GUIDE.md +++ b/AGENT_QUERY_GUIDE.md @@ -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 diff --git a/agents/the-knowing-one.md b/agents/the-knowing-one.md index 811180a..bdf7669 100644 --- a/agents/the-knowing-one.md +++ b/agents/the-knowing-one.md @@ -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. diff --git a/agents/the-scribe.md b/agents/the-scribe.md index bb0f18e..712d433 100644 --- a/agents/the-scribe.md +++ b/agents/the-scribe.md @@ -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 diff --git a/index/graph.json b/index/graph.json index b670de3..a8c9fba 100644 --- a/index/graph.json +++ b/index/graph.json @@ -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", diff --git a/scripts/generate-graph.py b/scripts/generate-graph.py index 1b3e87d..3ed08b9 100644 --- a/scripts/generate-graph.py +++ b/scripts/generate-graph.py @@ -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) diff --git a/scripts/mcp-server.py b/scripts/mcp-server.py index 5b6d428..53f51dd 100755 --- a/scripts/mcp-server.py +++ b/scripts/mcp-server.py @@ -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"), diff --git a/skills/knowledge-base-ingest-resource/SKILL.md b/skills/knowledge-base-ingest-resource/SKILL.md index 3b682e2..02d28f1 100644 --- a/skills/knowledge-base-ingest-resource/SKILL.md +++ b/skills/knowledge-base-ingest-resource/SKILL.md @@ -229,7 +229,8 @@ last_reviewed: - `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)`. diff --git a/skills/knowledge-base-read/SKILL.md b/skills/knowledge-base-read/SKILL.md index 6bf880a..e76f273 100644 --- a/skills/knowledge-base-read/SKILL.md +++ b/skills/knowledge-base-read/SKILL.md @@ -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`. diff --git a/tests/test_generate_graph.py b/tests/test_generate_graph.py new file mode 100644 index 0000000..4914c9c --- /dev/null +++ b/tests/test_generate_graph.py @@ -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() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index cb99e8c..8297831 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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"])