refactor: extract kb_framework package, build graph in memory
Scripts become thin CLIs over kb_framework (graph, query, query_tools, vault, instructions) so the hosted read-only connector shares one implementation with the stdio server. The MCP server builds the graph from Markdown on demand and invalidates it on writes; refresh_graph rebuilds in memory and reports validation problems. Pre-commit hook validates only; index/graph.json is no longer read, written or staged (generate-graph.py --write remains for operators). Golden fixtures pin graph and tool payloads to the previous output. Agent and skill docs updated to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+54
-466
@@ -1,439 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build `index/graph.json` from the vault's Markdown, and validate it.
|
||||
"""Validate the vault graph, and optionally write it to `index/graph.json`.
|
||||
|
||||
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]]`.
|
||||
Thin CLI over `kb_framework.graph`: the graph is built in memory from the
|
||||
vault's Markdown. `--validate` is the gate the pre-commit hook applies — 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]]`. `--write` is for operators who want the file; nothing else
|
||||
writes it, and the MCP servers never read it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, List
|
||||
|
||||
from vault_root import parse_vault_args
|
||||
|
||||
|
||||
# Obsidian-style wiki link: [[target#anchor|alias]]
|
||||
WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
||||
# Standard markdown link: [display](target); (?<!!) excludes 
|
||||
MD_LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(([^)]+)\)")
|
||||
# Trailing "— gloss" (em-dash / en-dash / hyphen) used as an annotation fallback
|
||||
GLOSS_RE = re.compile(r"^\s*[—–-]\s+(\S.*)$")
|
||||
FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL)
|
||||
INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
|
||||
# Markdown targets that are external (not vault edges, not unresolved-link errors)
|
||||
EXTERNAL_TARGET_RE = re.compile(r"^(?:[a-z][a-z0-9+.\-]*://|mailto:|tel:|#)", re.IGNORECASE)
|
||||
|
||||
# Strict controlled vocabulary for edge relationship types. A link with no brace
|
||||
# (or no "type") defaults to "related"; --validate fails on any type not in this set.
|
||||
RELATION_TYPES = {
|
||||
"related",
|
||||
"supersedes",
|
||||
"superseded-by",
|
||||
"depends-on",
|
||||
"part-of",
|
||||
"parent",
|
||||
"derived-from",
|
||||
"see-also",
|
||||
"source",
|
||||
}
|
||||
|
||||
|
||||
# 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)
|
||||
anchor: str # display / alias text -> edge anchor_text
|
||||
kind: str # "wiki_link" | "markdown_link"
|
||||
rel_type: str # semantic relationship type (default "related")
|
||||
annotation: str # per-edge "what to expect there" note
|
||||
lineno: int # 1-based line number in the note body
|
||||
raw: str # the raw link text as written (for the inventory script)
|
||||
is_external: bool # markdown link whose target is a URL/anchor -> no edge
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
path: Path
|
||||
rel_path: str
|
||||
stem: str
|
||||
meta: Dict[str, object]
|
||||
title: str
|
||||
note_id: str
|
||||
links: List[LinkRef]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnresolvedLink:
|
||||
from_id: str
|
||||
from_path: str
|
||||
target: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WikiLinkUse:
|
||||
from_id: str
|
||||
from_path: str
|
||||
target: str
|
||||
lineno: int
|
||||
raw: str
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> Tuple[Dict[str, object], str]:
|
||||
if not text.startswith("---\n"):
|
||||
return {}, text
|
||||
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return {}, text
|
||||
|
||||
fm_block = text[4:end]
|
||||
body = text[end + 5 :]
|
||||
meta: Dict[str, object] = {}
|
||||
|
||||
for raw in fm_block.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
if inner:
|
||||
meta[key] = [v.strip().strip('"').strip("'") for v in inner.split(",")]
|
||||
else:
|
||||
meta[key] = []
|
||||
else:
|
||||
meta[key] = value.strip('"').strip("'")
|
||||
|
||||
return meta, body
|
||||
|
||||
|
||||
def derive_title(meta: Dict[str, object], body: str, stem: str) -> str:
|
||||
if isinstance(meta.get("title"), str) and meta["title"].strip():
|
||||
return str(meta["title"]).strip()
|
||||
for line in body.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return stem.replace("-", " ").strip().title()
|
||||
|
||||
|
||||
def mask_code(body: str) -> str:
|
||||
"""Blank out code so links inside code samples are ignored, preserving line
|
||||
boundaries so line-based parsing (gloss fallback, line numbers) stays aligned."""
|
||||
body = FENCED_CODE_RE.sub(lambda m: "\n" * m.group(0).count("\n"), body)
|
||||
body = INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), body)
|
||||
return body
|
||||
|
||||
|
||||
def scan_brace(text: str, start: int) -> Tuple[object, int]:
|
||||
"""Given text[start] == '{', scan to the matching '}' respecting JSON string
|
||||
quoting, and json.loads the block. Returns (obj_or_None, end_index_after_brace).
|
||||
On an unterminated or malformed brace, returns (None, start) so the caller
|
||||
treats it as no brace."""
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
i = start
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
elif c == '"':
|
||||
in_str = True
|
||||
elif c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
raw = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(raw), i + 1
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None, start
|
||||
i += 1
|
||||
return None, start
|
||||
|
||||
|
||||
def clean_md_target(raw: str) -> str:
|
||||
"""Normalize a markdown link target: drop a trailing ``"title"`` and <> wrappers."""
|
||||
raw = raw.strip()
|
||||
if raw.startswith("<") and raw.endswith(">"):
|
||||
raw = raw[1:-1].strip()
|
||||
# `[text](target "title")` -> target
|
||||
for sep in (' "', " '"):
|
||||
if sep in raw:
|
||||
raw = raw.split(sep, 1)[0].strip()
|
||||
return raw
|
||||
|
||||
|
||||
def parse_links(body: str) -> List[LinkRef]:
|
||||
masked = mask_code(body)
|
||||
refs: List[LinkRef] = []
|
||||
|
||||
for lineno, line in enumerate(masked.splitlines(), start=1):
|
||||
# Collect wiki + markdown link matches on this line, in order.
|
||||
raw_matches: List[Tuple[int, int, str, str, str, str]] = []
|
||||
for m in WIKI_LINK_RE.finditer(line):
|
||||
target = m.group(1).strip()
|
||||
alias = (m.group(2) or target).strip()
|
||||
if target:
|
||||
raw_matches.append(
|
||||
(m.start(), m.end(), "wiki_link", target, alias, m.group(0))
|
||||
)
|
||||
for m in MD_LINK_RE.finditer(line):
|
||||
target = clean_md_target(m.group(2))
|
||||
display = m.group(1).strip()
|
||||
if target:
|
||||
raw_matches.append(
|
||||
(
|
||||
m.start(),
|
||||
m.end(),
|
||||
"markdown_link",
|
||||
target,
|
||||
display or target,
|
||||
m.group(0),
|
||||
)
|
||||
)
|
||||
raw_matches.sort(key=lambda x: x[0])
|
||||
|
||||
line_refs: List[Tuple[LinkRef, int]] = [] # (ref, end-index-after-brace)
|
||||
for start, end, kind, target, anchor, raw in raw_matches:
|
||||
rel_type, annotation = "related", ""
|
||||
brace_end = end
|
||||
if end < len(line) and line[end] == "{":
|
||||
obj, be = scan_brace(line, end)
|
||||
if isinstance(obj, dict):
|
||||
brace_end = be
|
||||
t = obj.get("type")
|
||||
if isinstance(t, str) and t.strip():
|
||||
rel_type = t.strip()
|
||||
info = obj.get("link-info")
|
||||
if isinstance(info, str) and info.strip():
|
||||
annotation = info.strip()
|
||||
is_external = kind == "markdown_link" and bool(
|
||||
EXTERNAL_TARGET_RE.match(target)
|
||||
)
|
||||
ref = LinkRef(
|
||||
target=target,
|
||||
anchor=anchor,
|
||||
kind=kind,
|
||||
rel_type=rel_type,
|
||||
annotation=annotation,
|
||||
lineno=lineno,
|
||||
raw=raw,
|
||||
is_external=is_external,
|
||||
)
|
||||
line_refs.append((ref, brace_end))
|
||||
|
||||
# Annotation fallback: a trailing "— gloss" applies only when the link is
|
||||
# the sole link on its line (so we don't mis-assign a shared gloss).
|
||||
if len(line_refs) == 1:
|
||||
ref, brace_end = line_refs[0]
|
||||
if not ref.annotation:
|
||||
gloss = GLOSS_RE.match(line[brace_end:])
|
||||
if gloss:
|
||||
ref.annotation = gloss.group(1).strip()
|
||||
|
||||
refs.extend(ref for ref, _ in line_refs)
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
return s.strip().lower()
|
||||
|
||||
|
||||
def build_notes(root: Path) -> List[Note]:
|
||||
notes: List[Note] = []
|
||||
for p in sorted(root.rglob("*.md")):
|
||||
rel = p.relative_to(root)
|
||||
if rel.parts and rel.parts[0] in {"index", "scripts", ".obsidian"}:
|
||||
continue
|
||||
# `raw/` holds untouched ingested originals (external READMEs, specs, …),
|
||||
# not vault notes — skip them so their file-relative markdown links are not
|
||||
# treated as (broken) graph edges.
|
||||
if "raw" in rel.parts:
|
||||
continue
|
||||
|
||||
text = p.read_text(encoding="utf-8")
|
||||
meta, body = parse_frontmatter(text)
|
||||
title = derive_title(meta, body, p.stem)
|
||||
note_id = str(meta.get("id") or rel.with_suffix("")).strip()
|
||||
links = parse_links(body)
|
||||
# parse_links numbers lines within `body`; shift to absolute file line
|
||||
# numbers by the number of lines the frontmatter block occupied.
|
||||
line_offset = text.count("\n", 0, len(text) - len(body))
|
||||
if line_offset:
|
||||
for ref in links:
|
||||
ref.lineno += line_offset
|
||||
|
||||
notes.append(
|
||||
Note(
|
||||
path=p,
|
||||
rel_path=rel.as_posix(),
|
||||
stem=p.stem,
|
||||
meta=meta,
|
||||
title=title,
|
||||
note_id=note_id,
|
||||
links=links,
|
||||
)
|
||||
)
|
||||
return notes
|
||||
|
||||
|
||||
def build_alias_map(notes: List[Note]) -> Dict[str, str]:
|
||||
alias_map: Dict[str, str] = {}
|
||||
stem_buckets: Dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
for n in notes:
|
||||
stem_buckets[norm(n.stem)].append(n.note_id)
|
||||
|
||||
for n in notes:
|
||||
alias_map[norm(n.note_id)] = n.note_id
|
||||
alias_map[norm(n.rel_path)] = n.note_id
|
||||
alias_map[norm(Path(n.rel_path).with_suffix("").as_posix())] = n.note_id
|
||||
alias_map[norm(n.title)] = n.note_id
|
||||
|
||||
for stem, ids in stem_buckets.items():
|
||||
if len(ids) == 1:
|
||||
alias_map[stem] = ids[0]
|
||||
|
||||
return alias_map
|
||||
|
||||
|
||||
def generate_graph(
|
||||
root: Path, existing_generated_at: str = ""
|
||||
) -> Tuple[Dict[str, object], List[UnresolvedLink], List[WikiLinkUse]]:
|
||||
notes = build_notes(root)
|
||||
alias_map = build_alias_map(notes)
|
||||
|
||||
edges_set = set()
|
||||
outbound: Dict[str, List[str]] = defaultdict(list)
|
||||
inbound_count: Dict[str, int] = defaultdict(int)
|
||||
unresolved_set = set()
|
||||
wiki_uses_set = set()
|
||||
|
||||
for n in notes:
|
||||
for ref in n.links:
|
||||
# Legacy [[wiki links]] are still parsed (so we can flag them) but are
|
||||
# no longer an accepted link form — --validate rejects any that remain.
|
||||
if ref.kind == "wiki_link":
|
||||
wiki_uses_set.add(
|
||||
WikiLinkUse(
|
||||
from_id=n.note_id,
|
||||
from_path=n.rel_path,
|
||||
target=ref.target,
|
||||
lineno=ref.lineno,
|
||||
raw=ref.raw,
|
||||
)
|
||||
)
|
||||
if ref.is_external:
|
||||
continue
|
||||
target = alias_map.get(norm(ref.target))
|
||||
if not target:
|
||||
unresolved_set.add(
|
||||
UnresolvedLink(
|
||||
from_id=n.note_id,
|
||||
from_path=n.rel_path,
|
||||
target=ref.target,
|
||||
)
|
||||
)
|
||||
continue
|
||||
edge = (n.note_id, target, ref.kind, ref.rel_type, ref.annotation, ref.anchor)
|
||||
if edge in edges_set:
|
||||
continue
|
||||
edges_set.add(edge)
|
||||
outbound[n.note_id].append(target)
|
||||
inbound_count[target] += 1
|
||||
|
||||
nodes = []
|
||||
for n in sorted(notes, key=lambda x: x.note_id):
|
||||
tags = n.meta.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
nodes.append(
|
||||
{
|
||||
"id": n.note_id,
|
||||
"title": n.title,
|
||||
"path": n.rel_path,
|
||||
"repo": n.meta.get("repo", "global"),
|
||||
"type": n.meta.get("type", "note"),
|
||||
"status": n.meta.get("status", "active"),
|
||||
"tags": tags,
|
||||
"last_reviewed": n.meta.get("last_reviewed", ""),
|
||||
"outbound_links": sorted(set(outbound.get(n.note_id, []))),
|
||||
"inbound_count": inbound_count.get(n.note_id, 0),
|
||||
}
|
||||
)
|
||||
|
||||
edges = [
|
||||
{
|
||||
"from": src,
|
||||
"to": dst,
|
||||
"kind": kind,
|
||||
"type": rel_type,
|
||||
"anchor_text": anchor,
|
||||
"annotation": annotation,
|
||||
"weight": 1.0,
|
||||
}
|
||||
for src, dst, kind, rel_type, annotation, anchor in sorted(edges_set)
|
||||
]
|
||||
|
||||
graph = {
|
||||
"version": 1,
|
||||
"generated_at": existing_generated_at
|
||||
if existing_generated_at
|
||||
else datetime.now(timezone.utc).isoformat(),
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
unresolved = sorted(unresolved_set, key=lambda x: (x.from_path, x.target))
|
||||
wiki_uses = sorted(wiki_uses_set, key=lambda x: (x.from_path, x.lineno, x.raw))
|
||||
return graph, unresolved, wiki_uses
|
||||
from kb_framework.graph import ( # noqa: F401 (re-exported for list-wiki-links.py and tests)
|
||||
NOTE_TYPES,
|
||||
RELATION_TYPES,
|
||||
GraphBuild,
|
||||
LinkRef,
|
||||
Note,
|
||||
UnresolvedLink,
|
||||
WikiLinkUse,
|
||||
build_alias_map,
|
||||
build_graph,
|
||||
build_notes,
|
||||
find_invalid_node_types,
|
||||
find_invalid_types,
|
||||
generate_graph,
|
||||
norm,
|
||||
parse_frontmatter,
|
||||
parse_links,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate and validate Obsidian graph JSON"
|
||||
description="Validate the vault graph; optionally write index/graph.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--validate",
|
||||
action="store_true",
|
||||
help="validate the graph: unresolved targets, edge relationship types, "
|
||||
"note types, "
|
||||
"and legacy [[wiki links]] (all rejected)",
|
||||
"note types, and legacy [[wiki links]] (all rejected)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write",
|
||||
@@ -447,7 +59,6 @@ def print_unresolved(unresolved: List[UnresolvedLink]) -> None:
|
||||
if not unresolved:
|
||||
print("Validation passed: no unresolved links")
|
||||
return
|
||||
|
||||
print(f"Validation failed: {len(unresolved)} unresolved link(s)")
|
||||
for link in unresolved:
|
||||
print(f"- {link.from_path}: [[{link.target}]]")
|
||||
@@ -457,92 +68,69 @@ def print_wiki_links(wiki_uses: List[WikiLinkUse]) -> None:
|
||||
if not wiki_uses:
|
||||
print("Validation passed: no legacy [[wiki links]]")
|
||||
return
|
||||
|
||||
print(f"Validation failed: {len(wiki_uses)} legacy [[wiki link(s)]]")
|
||||
print(" use typed markdown links; find/convert them with scripts/list-wiki-links.py")
|
||||
for w in wiki_uses:
|
||||
print(f"- {w.from_path}:{w.lineno}: {w.raw}")
|
||||
|
||||
|
||||
def find_invalid_types(graph: Dict[str, object]) -> List[Dict[str, object]]:
|
||||
return [
|
||||
e
|
||||
for e in graph.get("edges", []) # type: ignore[union-attr]
|
||||
if e.get("type") not in RELATION_TYPES
|
||||
]
|
||||
|
||||
|
||||
def print_invalid_types(invalid: List[Dict[str, object]]) -> None:
|
||||
if not invalid:
|
||||
print("Validation passed: all edge types are in the controlled vocabulary")
|
||||
return
|
||||
|
||||
print(f"Validation failed: {len(invalid)} edge(s) with unknown relationship type")
|
||||
print(f" allowed: {', '.join(sorted(RELATION_TYPES))}")
|
||||
for e in invalid:
|
||||
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 _existing_generated_at(output: Path, candidate: GraphBuild) -> str:
|
||||
"""Keep the old timestamp when the written graph would be unchanged."""
|
||||
if not output.exists():
|
||||
return ""
|
||||
try:
|
||||
existing = json.loads(output.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
return ""
|
||||
if (
|
||||
candidate.graph["nodes"] == existing.get("nodes", [])
|
||||
and candidate.graph["edges"] == existing.get("edges", [])
|
||||
):
|
||||
return str(existing.get("generated_at", ""))
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = args.vault_root
|
||||
output = root / "index" / "graph.json"
|
||||
should_write = args.write or not args.validate
|
||||
|
||||
build = build_graph(root)
|
||||
if should_write:
|
||||
stamp = _existing_generated_at(output, build)
|
||||
if stamp:
|
||||
build = build_graph(root, existing_generated_at=stamp)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing_generated_at = ""
|
||||
if output.exists():
|
||||
try:
|
||||
existing = json.loads(output.read_text(encoding="utf-8"))
|
||||
existing_nodes = existing.get("nodes", [])
|
||||
existing_edges = existing.get("edges", [])
|
||||
candidate_graph, unresolved, wiki_uses = generate_graph(root)
|
||||
if (
|
||||
candidate_graph["nodes"] == existing_nodes
|
||||
and candidate_graph["edges"] == existing_edges
|
||||
):
|
||||
existing_generated_at = str(existing.get("generated_at", ""))
|
||||
graph, unresolved, wiki_uses = generate_graph(
|
||||
root, existing_generated_at=existing_generated_at
|
||||
)
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
graph, unresolved, wiki_uses = generate_graph(root)
|
||||
else:
|
||||
graph, unresolved, wiki_uses = generate_graph(root)
|
||||
|
||||
if should_write:
|
||||
output.write_text(json.dumps(graph, indent=2) + "\n", encoding="utf-8")
|
||||
output.write_text(json.dumps(build.graph, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {output}")
|
||||
|
||||
if args.validate:
|
||||
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 invalid_nodes or wiki_uses:
|
||||
print_unresolved(build.unresolved)
|
||||
print_invalid_types(build.invalid_edge_types)
|
||||
print_invalid_node_types(build.invalid_node_types)
|
||||
print_wiki_links(build.wiki_uses)
|
||||
if not build.ok:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
+52
-347
@@ -4,7 +4,9 @@
|
||||
This server is the single interface an agent needs to work the vault over stdio
|
||||
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 — all served by the shared
|
||||
`kb_framework` package over a graph built in memory from the vault's Markdown
|
||||
and cached until the next write; refresh_graph rebuilds it), *writing*
|
||||
(write_note for text, capture_raw for verbatim bytes from a local file or an
|
||||
http(s) download) and *publishing* (edit_note, git_commit, git_push), so an
|
||||
agent without shell or filesystem tools can ingest a resource end to end.
|
||||
@@ -27,21 +29,35 @@ 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
|
||||
|
||||
from vault_root import FRAMEWORK_ROOT, parse_vault_args
|
||||
from vault_root import FRAMEWORK_ROOT, parse_vault_args # noqa: E402
|
||||
|
||||
from kb_framework import graph as kb_graph # noqa: E402
|
||||
from kb_framework import query_tools # noqa: E402
|
||||
from kb_framework import vault as kb_vault # noqa: E402
|
||||
|
||||
SERVER_NAME = "knowledge-base-mcp"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass
|
||||
class AppState:
|
||||
"""The selected vault plus the graph built from it, cached until a write."""
|
||||
|
||||
root: Path
|
||||
graph_path: Path
|
||||
graph_cache: Dict[str, Any] | None = None
|
||||
|
||||
def graph(self) -> Dict[str, Any]:
|
||||
if self.graph_cache is None:
|
||||
self.graph_cache = kb_graph.build_graph(self.root).graph
|
||||
return self.graph_cache
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self.graph_cache = None
|
||||
|
||||
|
||||
class _StdinReader:
|
||||
@@ -140,7 +156,6 @@ def _write_message_ndjson(stdout: Any, payload: Dict[str, Any]) -> None:
|
||||
stdout.buffer.flush()
|
||||
|
||||
|
||||
|
||||
def _response(message_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": message_id, "result": result}
|
||||
|
||||
@@ -153,47 +168,9 @@ def _error(message_id: Any, code: int, message: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _load_graph(state: AppState) -> Dict[str, Any]:
|
||||
if not state.graph_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"graph not found at {state.graph_path}. Run scripts/generate-graph.py first."
|
||||
)
|
||||
return json.loads(state.graph_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _nodes_by_id(graph: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
nodes = graph.get("nodes", [])
|
||||
return {str(node.get("id")): node for node in nodes if isinstance(node, dict)}
|
||||
|
||||
|
||||
def _match_filters(node: Dict[str, Any], args: Dict[str, Any]) -> bool:
|
||||
repo = args.get("repo")
|
||||
note_type = args.get("type")
|
||||
status = args.get("status")
|
||||
tag = args.get("tag")
|
||||
query = str(args.get("query", "")).strip().lower()
|
||||
|
||||
if repo and node.get("repo") != repo:
|
||||
return False
|
||||
if note_type and node.get("type") != note_type:
|
||||
return False
|
||||
if status and node.get("status") != status:
|
||||
return False
|
||||
if tag and tag not in (node.get("tags") or []):
|
||||
return False
|
||||
if query:
|
||||
haystacks = [
|
||||
str(node.get("id", "")).lower(),
|
||||
str(node.get("title", "")).lower(),
|
||||
str(node.get("path", "")).lower(),
|
||||
" ".join(str(t).lower() for t in (node.get("tags") or [])),
|
||||
]
|
||||
if not any(query in h for h in haystacks):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
|
||||
# Reserved for operator-written graph dumps (generate-graph.py --write); never a note.
|
||||
_GENERATED_GRAPH_PATH = "index/graph.json"
|
||||
|
||||
# Ceiling for a single capture_raw call; papers and slide decks fit, a video
|
||||
# download that would bloat the vault does not.
|
||||
@@ -202,31 +179,15 @@ _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.
|
||||
|
||||
Rejects empty values, absolute paths and anything escaping the root, so a
|
||||
tool call can never touch a file outside the vault.
|
||||
"""
|
||||
value = str(raw_path or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"'{label}' is required")
|
||||
candidate = Path(value)
|
||||
if candidate.is_absolute():
|
||||
raise ValueError(f"'{label}' must be vault-relative, got absolute '{value}'")
|
||||
root = state.root.resolve()
|
||||
target = (root / candidate).resolve()
|
||||
if target != root and not target.is_relative_to(root):
|
||||
raise ValueError(f"'{label}' resolves outside repository root: '{value}'")
|
||||
return target
|
||||
"""Vault-relative path -> absolute path inside the repo root (never outside)."""
|
||||
return kb_vault.resolve_in_root(state.root, raw_path, label=label)
|
||||
|
||||
|
||||
def _relative_to_root(state: AppState, target: Path) -> str:
|
||||
return target.relative_to(state.root.resolve()).as_posix()
|
||||
return kb_vault.relative_to_root(state.root, target)
|
||||
|
||||
|
||||
def _run_git(state: AppState, args: List[str]) -> subprocess.CompletedProcess:
|
||||
@@ -258,99 +219,15 @@ def _git_result(payload: Dict[str, Any], exit_code: int) -> Dict[str, Any]:
|
||||
|
||||
def _tool_definitions() -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "read_instructions",
|
||||
"description": (
|
||||
"Read framework AGENTS.md and AGENT_QUERY_GUIDE.md before working "
|
||||
"with the vault. These operating documents live outside an "
|
||||
"external vault and do not require a generated graph."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "graph_summary",
|
||||
"description": (
|
||||
"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,
|
||||
"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,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "graph_search_nodes",
|
||||
"description": "Search graph nodes by metadata and free text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"tag": {"type": "string"},
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "graph_neighbors",
|
||||
"description": (
|
||||
"Show inbound and outbound links for a node ID. Each neighbor "
|
||||
"includes the edge `type` (relationship, e.g. supersedes, "
|
||||
"depends-on), `anchor_text` (the link's display text), and "
|
||||
"`annotation` (what to expect in that note)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "read_note",
|
||||
"description": (
|
||||
"Read note content by graph node ID or vault-relative path. "
|
||||
"Provide exactly one of `id` or `path`. "
|
||||
"If both or neither are provided, the call returns an error."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Graph node ID (e.g. 'AGENTS')."},
|
||||
"path": {"type": "string", "description": "Vault-relative file path (e.g. 'README.md')."},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
*query_tools.definitions(),
|
||||
{
|
||||
"name": "refresh_graph",
|
||||
"description": "Regenerate graph.json using scripts/generate-graph.py.",
|
||||
"description": (
|
||||
"Rebuild the in-memory graph from the vault's Markdown. With "
|
||||
"validate=true also run the commit gate (unresolved links, "
|
||||
"unknown edge/note types, legacy wiki links) and return an error "
|
||||
"result listing the problems."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -496,187 +373,20 @@ def _text_result(text: str, structured: Dict[str, Any] | None = None) -> Dict[st
|
||||
return result
|
||||
|
||||
|
||||
def _tool_read_instructions() -> Dict[str, Any]:
|
||||
documents = [
|
||||
{"path": name, "content": (FRAMEWORK_ROOT / name).read_text(encoding="utf-8")}
|
||||
for name in ("AGENTS.md", "AGENT_QUERY_GUIDE.md")
|
||||
]
|
||||
payload = {"framework_root": str(FRAMEWORK_ROOT), "documents": documents}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
graph = _load_graph(state)
|
||||
nodes = graph.get("nodes", [])
|
||||
edges = graph.get("edges", [])
|
||||
top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
|
||||
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
|
||||
|
||||
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,
|
||||
key=lambda n: int(n.get("inbound_count", 0)),
|
||||
reverse=True,
|
||||
)[:top_n]
|
||||
payload = {
|
||||
"version": graph.get("version"),
|
||||
"generated_at": graph.get("generated_at"),
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
"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"),
|
||||
"title": node.get("title"),
|
||||
"path": node.get("path"),
|
||||
"inbound_count": node.get("inbound_count", 0),
|
||||
}
|
||||
for node in sorted_nodes
|
||||
],
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_graph_search_nodes(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
graph = _load_graph(state)
|
||||
nodes = graph.get("nodes", [])
|
||||
limit = int(args.get("limit", 30))
|
||||
filtered = [node for node in nodes if _match_filters(node, args)]
|
||||
filtered.sort(key=lambda n: str(n.get("id", "")))
|
||||
sliced = filtered[:limit]
|
||||
payload = {
|
||||
"total_matches": len(filtered),
|
||||
"returned": len(sliced),
|
||||
"items": sliced,
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_graph_neighbors(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
graph = _load_graph(state)
|
||||
node_id = str(args["id"])
|
||||
limit = int(args.get("limit", 50))
|
||||
nodes = _nodes_by_id(graph)
|
||||
if node_id not in nodes:
|
||||
raise ValueError(f"node '{node_id}' not found")
|
||||
|
||||
edges = graph.get("edges", [])
|
||||
outbound_edges = [e for e in edges if e.get("from") == node_id]
|
||||
inbound_edges = [e for e in edges if e.get("to") == node_id]
|
||||
|
||||
def describe(edge_list: List[Dict[str, Any]], direction: str) -> List[Dict[str, Any]]:
|
||||
# neighbor id is the "other end" relative to node_id
|
||||
other_key = "to" if direction == "outbound" else "from"
|
||||
result: List[Dict[str, Any]] = []
|
||||
for edge in edge_list:
|
||||
item_id = str(edge.get(other_key))
|
||||
node = nodes.get(item_id)
|
||||
entry = {
|
||||
"id": node.get("id") if node else item_id,
|
||||
"title": node.get("title") if node else "<missing>",
|
||||
"path": node.get("path") if node else "",
|
||||
"direction": direction,
|
||||
"type": edge.get("type", "related"),
|
||||
"anchor_text": edge.get("anchor_text", ""),
|
||||
"annotation": edge.get("annotation", ""),
|
||||
}
|
||||
result.append(entry)
|
||||
return result[:limit]
|
||||
|
||||
payload = {
|
||||
"node": nodes[node_id],
|
||||
"inbound_count": len(inbound_edges),
|
||||
"outbound_count": len(outbound_edges),
|
||||
"inbound": describe(inbound_edges, "inbound"),
|
||||
"outbound": describe(outbound_edges, "outbound"),
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_read_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
has_id = bool(args.get("id"))
|
||||
has_path = bool(args.get("path"))
|
||||
|
||||
if has_id and has_path:
|
||||
raise ValueError("provide exactly one of 'id' or 'path', not both")
|
||||
if not has_id and not has_path:
|
||||
raise ValueError("exactly one of 'id' or 'path' is required")
|
||||
|
||||
path_value = args.get("path")
|
||||
|
||||
if has_id:
|
||||
nodes = _nodes_by_id(_load_graph(state))
|
||||
node = nodes.get(str(args["id"]))
|
||||
if not node:
|
||||
raise ValueError(f"node '{args['id']}' not found")
|
||||
path_value = node.get("path")
|
||||
|
||||
target = (state.root / str(path_value)).resolve()
|
||||
if not target.is_relative_to(state.root.resolve()):
|
||||
raise ValueError("note path resolves outside repository root")
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"note not found at {target}")
|
||||
|
||||
relative = str(target.relative_to(state.root).as_posix())
|
||||
data = target.read_bytes()
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
payload = {
|
||||
"path": relative,
|
||||
"binary": True,
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"hint": (
|
||||
f"{target} is not UTF-8 text (e.g. a PDF or image captured under "
|
||||
"raw/). Open it with a file-reading tool that handles binaries; "
|
||||
"this server returns text only."
|
||||
),
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
payload = {
|
||||
"path": relative,
|
||||
"binary": False,
|
||||
"content": text,
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_refresh_graph(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
command = [
|
||||
sys.executable, str(FRAMEWORK_ROOT / "scripts/generate-graph.py"),
|
||||
"--vault-root", str(state.root), "--write",
|
||||
]
|
||||
if bool(args.get("validate", False)):
|
||||
command.append("--validate")
|
||||
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
cwd=state.root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
"""Rebuild the in-memory graph; with validate=true also apply the commit gate."""
|
||||
build = kb_graph.build_graph(state.root)
|
||||
state.graph_cache = build.graph
|
||||
problems = kb_graph.validation_problems(build) if bool(args.get("validate", False)) else []
|
||||
payload = {
|
||||
"command": command,
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
"vault_root": str(state.root),
|
||||
"generated_at": build.graph.get("generated_at"),
|
||||
"node_count": len(build.graph.get("nodes", [])),
|
||||
"edge_count": len(build.graph.get("edges", [])),
|
||||
"validated": bool(args.get("validate", False)),
|
||||
"problems": problems,
|
||||
}
|
||||
if proc.returncode != 0:
|
||||
if problems:
|
||||
return {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
|
||||
@@ -691,7 +401,7 @@ def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
content = str(args["content"])
|
||||
target = _resolve_in_root(state, args.get("path"))
|
||||
relative = _relative_to_root(state, target)
|
||||
if relative == state.graph_path.relative_to(state.root.resolve()).as_posix():
|
||||
if relative == _GENERATED_GRAPH_PATH:
|
||||
raise ValueError(
|
||||
"index/graph.json is generated \u2014 use refresh_graph instead of writing it"
|
||||
)
|
||||
@@ -706,6 +416,7 @@ def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
state.invalidate()
|
||||
payload = {
|
||||
"path": relative,
|
||||
"created": not existed,
|
||||
@@ -742,6 +453,7 @@ def _tool_edit_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
updated = text.replace(old_string, new_string)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
state.invalidate()
|
||||
payload = {
|
||||
"path": relative,
|
||||
"replacements": occurrences if replace_all else 1,
|
||||
@@ -818,6 +530,7 @@ def _tool_capture_raw(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
source_label = url
|
||||
state.invalidate()
|
||||
|
||||
payload = {
|
||||
"path": relative,
|
||||
@@ -918,16 +631,8 @@ def _tool_git_push(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if name == "read_instructions":
|
||||
return _tool_read_instructions()
|
||||
if name == "graph_summary":
|
||||
return _tool_graph_summary(state, arguments)
|
||||
if name == "graph_search_nodes":
|
||||
return _tool_graph_search_nodes(state, arguments)
|
||||
if name == "graph_neighbors":
|
||||
return _tool_graph_neighbors(state, arguments)
|
||||
if name == "read_note":
|
||||
return _tool_read_note(state, arguments)
|
||||
if name in query_tools.READ_ONLY_TOOL_NAMES:
|
||||
return query_tools.call(name, arguments, graph=state.graph(), root=state.root)
|
||||
if name == "refresh_graph":
|
||||
return _tool_refresh_graph(state, arguments)
|
||||
if name == "write_note":
|
||||
@@ -949,7 +654,7 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
|
||||
{
|
||||
"uri": "repo://graph",
|
||||
"name": "Repository Graph",
|
||||
"description": "Raw index/graph.json",
|
||||
"description": "The graph built in memory from the vault's Markdown",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
@@ -970,7 +675,7 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
|
||||
|
||||
def _read_resource(state: AppState, uri: str) -> Dict[str, Any]:
|
||||
if uri == "repo://graph":
|
||||
text = state.graph_path.read_text(encoding="utf-8")
|
||||
text = json.dumps(state.graph(), indent=2)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
@@ -1038,7 +743,7 @@ def _handle_request(state: AppState, message: Dict[str, Any]) -> Dict[str, Any]
|
||||
def main() -> None:
|
||||
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)
|
||||
|
||||
reader = _StdinReader(sys.stdin.buffer)
|
||||
try:
|
||||
|
||||
+4
-10
@@ -1,15 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate and validate the selected vault graph before accepting a commit.
|
||||
# Validate the selected vault graph before accepting a commit.
|
||||
# The graph is derived in memory; nothing is written and nothing must be staged.
|
||||
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..."
|
||||
"${PYTHON:-python3}" "$framework_root/scripts/generate-graph.py" --vault-root "$vault_root" --write --validate
|
||||
|
||||
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] Stage the generated index/graph.json before committing."
|
||||
exit 1
|
||||
fi
|
||||
echo "[pre-commit] Validating graph..."
|
||||
"${PYTHON:-python3}" "$framework_root/scripts/generate-graph.py" --vault-root "$vault_root" --validate
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Resolve the selected data directory consistently for framework command-line tools."""
|
||||
"""Resolve the selected data directory consistently for framework command-line tools.
|
||||
|
||||
Also puts the framework root on `sys.path` so every script can import the
|
||||
`kb_framework` package from a plain checkout without installing it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRAMEWORK_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(FRAMEWORK_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(FRAMEWORK_ROOT))
|
||||
|
||||
|
||||
def parse_vault_args(parser: argparse.ArgumentParser) -> argparse.Namespace:
|
||||
|
||||
Reference in New Issue
Block a user