Tome is opt-in per project — each project declares its own knowledge management approach. Spire retains a small knowledge-awareness skill that explains what tome does and provides a fallback strategy for projects without it. - New plugins/tome/ with own manifests, README, all 5 sub-skills - Spire keeps skills/knowledge/SKILL.md as awareness stub - Updated all READMEs and marketplace JSONs - tome: namespace refs now correct (tome is its own plugin)
2187 lines
80 KiB
Python
2187 lines
80 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = []
|
|
# ///
|
|
"""
|
|
tome knowledge.py — Deterministic knowledge-layer tool for the tome skill family.
|
|
|
|
Subcommands:
|
|
bootstrap Scaffold wiki/ and navigate skill supporting files.
|
|
refresh Re-generate status:generated files; report authored/stub candidates.
|
|
context <path> Resolve navigation context for a target path.
|
|
check Validate knowledge-layer health; exit 1 on errors.
|
|
impact [paths...] Analyze which wiki docs are affected by changed source paths.
|
|
|
|
Usage (from repo root, via Justfile module):
|
|
just tome <subcommand> [args]
|
|
|
|
Or directly:
|
|
uv run -q --script <path-to-tome>/bin/knowledge.py <subcommand> [args]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, NamedTuple
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
EXCLUDED_DIRS = {
|
|
".git", "target", "node_modules", "wiki", "dist", "archive", "__pycache__",
|
|
}
|
|
|
|
CONTROL_NAMES = ("CLAUDE.md", "AGENTS.md")
|
|
WIKI_SUBDIR = "wiki"
|
|
CODEBASE_SUBDIR = "codebase"
|
|
BOOT_MD_REL = "wiki/boot.md"
|
|
WIKI_README_REL = "wiki/README.md"
|
|
WIKI_NAV_REL = "wiki/NAV.md"
|
|
WIKI_CODEBASE_NAV_REL = "wiki/codebase/NAV.md"
|
|
WIKI_INVENTORY_REL = "wiki/.inventory.md"
|
|
WIKI_CODEBASE_INVENTORY_REL = "wiki/codebase/.inventory.md"
|
|
|
|
# Canonical H2 order
|
|
CANONICAL_SECTIONS = ["Load", "Warnings", "Handoff"]
|
|
|
|
# Top-level dirs that are clearly source/code (not meta)
|
|
TOP_LEVEL_SOURCE_DIRS_HINTS = {"src", "crates", "scripts", "assets", "docs", "experiments"}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exit codes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
EXIT_OK = 0
|
|
EXIT_CHECK_ERRORS = 1 # check: >=1 error-severity finding
|
|
EXIT_USAGE = 2 # context: path outside repo / nonexistent / invalid args
|
|
EXIT_REPO_DISCOVERY = 3 # not a git repo
|
|
EXIT_PARSE = 4 # parse failure (context)
|
|
EXIT_INTERNAL = 5 # internal error (bootstrap/refresh)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Repository root discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def find_repo_root() -> Path:
|
|
"""Discover repo root via git rev-parse --show-toplevel."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
return Path(result.stdout.strip())
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
print("Repository discovery failed: not a git repository", file=sys.stderr)
|
|
sys.exit(EXIT_REPO_DISCOVERY)
|
|
|
|
|
|
def to_posix_rel(path: Path, root: Path) -> str:
|
|
"""Return a POSIX-separator, repo-relative string for a path."""
|
|
try:
|
|
rel = path.relative_to(root)
|
|
except ValueError:
|
|
rel = path
|
|
return rel.as_posix()
|
|
|
|
|
|
def posix_rel(path: Path, root: Path) -> str:
|
|
return to_posix_rel(path, root)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filesystem traversal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def should_exclude(name: str) -> bool:
|
|
"""Return True if a directory name should be excluded from traversal."""
|
|
return name in EXCLUDED_DIRS or name.startswith(".")
|
|
|
|
|
|
def walk_dirs(root: Path, include_root: bool = False):
|
|
"""Yield all non-excluded directories under root (breadth-first), optionally root itself."""
|
|
if include_root:
|
|
yield root
|
|
stack = [root]
|
|
while stack:
|
|
current = stack.pop()
|
|
try:
|
|
entries = sorted(current.iterdir(), key=lambda p: p.name)
|
|
except PermissionError:
|
|
continue
|
|
for entry in entries:
|
|
if entry.is_dir() and not should_exclude(entry.name):
|
|
yield entry
|
|
stack.append(entry)
|
|
|
|
|
|
def top_level_dirs(root: Path) -> list[Path]:
|
|
"""Return sorted top-level directories that are not excluded."""
|
|
result = []
|
|
try:
|
|
for entry in sorted(root.iterdir(), key=lambda p: p.name):
|
|
if entry.is_dir() and not should_exclude(entry.name):
|
|
result.append(entry)
|
|
except PermissionError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def find_control_file(directory: Path) -> Path | None:
|
|
"""Return the effective control file for a directory (CLAUDE.md wins, else AGENTS.md)."""
|
|
claude = directory / "CLAUDE.md"
|
|
agents = directory / "AGENTS.md"
|
|
if claude.exists():
|
|
return claude
|
|
if agents.exists():
|
|
return agents
|
|
return None
|
|
|
|
|
|
def has_both_control_files(directory: Path) -> bool:
|
|
return (directory / "CLAUDE.md").exists() and (directory / "AGENTS.md").exists()
|
|
|
|
|
|
def find_all_control_files(root: Path) -> list[Path]:
|
|
"""Return sorted list of all control files in the repo (excluding excluded dirs)."""
|
|
result = []
|
|
for d in walk_dirs(root, include_root=True):
|
|
for name in CONTROL_NAMES:
|
|
f = d / name
|
|
if f.exists():
|
|
result.append(f)
|
|
return sorted(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Front-matter parsing (hand-rolled, stdlib only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FrontMatter(NamedTuple):
|
|
status: str | None # generated / authored / stub / None
|
|
source_paths: list[str] # list of repo-relative POSIX paths
|
|
raw: str # the raw front-matter block (without --- delimiters)
|
|
present: bool # was a front-matter block found at all?
|
|
|
|
|
|
def parse_frontmatter(text: str) -> tuple[FrontMatter, str]:
|
|
"""
|
|
Parse YAML front-matter from a markdown file.
|
|
Returns (FrontMatter, body_without_frontmatter).
|
|
Only handles 'status:' and 'source-paths:' fields.
|
|
"""
|
|
if not text.startswith("---"):
|
|
return FrontMatter(None, [], "", False), text
|
|
|
|
# Find closing ---
|
|
rest = text[3:]
|
|
# skip optional newline
|
|
if rest.startswith("\n"):
|
|
rest = rest[1:]
|
|
end = rest.find("\n---")
|
|
if end == -1:
|
|
return FrontMatter(None, [], "", False), text
|
|
|
|
raw = rest[:end]
|
|
body = rest[end + 4:] # skip \n---
|
|
if body.startswith("\n"):
|
|
body = body[1:]
|
|
|
|
status = None
|
|
source_paths: list[str] = []
|
|
|
|
in_source_paths = False
|
|
for line in raw.splitlines():
|
|
stripped = line.strip()
|
|
# Continuation of a multi-line YAML list (indented "- item" lines)
|
|
if in_source_paths:
|
|
if stripped.startswith("- "):
|
|
val = stripped[2:].strip().strip('"').strip("'")
|
|
if val:
|
|
source_paths.append(val)
|
|
continue
|
|
else:
|
|
in_source_paths = False
|
|
if stripped.startswith("status:"):
|
|
val = stripped[len("status:"):].strip().strip('"').strip("'")
|
|
status = val
|
|
elif stripped.startswith("source-paths:"):
|
|
val = stripped[len("source-paths:"):].strip()
|
|
# parse simple inline list: [a, b] or []
|
|
if val.startswith("[") and val.endswith("]"):
|
|
inner = val[1:-1].strip()
|
|
if inner:
|
|
source_paths = [p.strip().strip('"').strip("'") for p in inner.split(",") if p.strip()]
|
|
elif not val:
|
|
# Multi-line YAML list follows on subsequent lines
|
|
in_source_paths = True
|
|
|
|
return FrontMatter(status, source_paths, raw, True), body
|
|
|
|
|
|
def render_frontmatter(status: str, source_paths: list[str]) -> str:
|
|
"""Render a front-matter block string (without outer --- lines)."""
|
|
if source_paths:
|
|
paths_str = ", ".join(f"{p}" for p in source_paths)
|
|
sp_line = f"source-paths: [{paths_str}]"
|
|
else:
|
|
sp_line = "source-paths: []"
|
|
return f"status: {status}\n{sp_line}"
|
|
|
|
|
|
def render_frontmatter_block(status: str, source_paths: list[str]) -> str:
|
|
"""Render complete ---...--- front-matter block."""
|
|
return f"---\n{render_frontmatter(status, source_paths)}\n---\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Control file parsing (CLAUDE.md / AGENTS.md)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INCLUDE_LINE_RE = re.compile(r"^@([A-Za-z0-9_./\\-]+)\s*$")
|
|
|
|
|
|
def expand_includes(text: str, base_dir: Path, depth: int = 0) -> str:
|
|
"""Expand `@path` include lines (the Claude Code @-include convention).
|
|
|
|
A line consisting solely of `@relative/path` is replaced by that file's
|
|
content, resolved relative to base_dir. Recursion is capped to avoid cycles.
|
|
Unresolvable includes are kept verbatim.
|
|
"""
|
|
if depth >= 3 or "@" not in text:
|
|
return text
|
|
out_lines = []
|
|
for line in text.splitlines():
|
|
m = INCLUDE_LINE_RE.match(line.strip())
|
|
if m:
|
|
inc_path = (base_dir / m.group(1)).resolve()
|
|
if inc_path.is_file():
|
|
try:
|
|
inc_text = inc_path.read_text(encoding="utf-8", errors="replace")
|
|
out_lines.append(expand_includes(inc_text, inc_path.parent, depth + 1))
|
|
continue
|
|
except OSError:
|
|
pass
|
|
out_lines.append(line)
|
|
return "\n".join(out_lines)
|
|
|
|
|
|
def is_pure_include(path: Path) -> bool:
|
|
"""True if a control file's non-blank lines are all `@path` includes."""
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return False
|
|
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
return bool(lines) and all(INCLUDE_LINE_RE.match(l) for l in lines)
|
|
|
|
class LoadItem(NamedTuple):
|
|
kind: str # wiki_nav / domain / pattern / layer / link / prose
|
|
label: str # original label text
|
|
target: str | None # resolved target path or None for prose
|
|
raw: str # original bullet text
|
|
|
|
|
|
class ControlFile(NamedTuple):
|
|
path: Path
|
|
rel: str # repo-relative POSIX
|
|
title: str | None
|
|
area: str | None # Area: value
|
|
knowledge: str | None # Knowledge: link target
|
|
load_items: list[LoadItem]
|
|
warnings: list[str]
|
|
handoff: list[str]
|
|
first_para: str | None # first paragraph after H1 (for purpose seeding)
|
|
section_order: list[str] # recognized sections in parse order
|
|
|
|
|
|
LOAD_LABEL_MAP = {
|
|
"Local navigation:": "wiki_nav",
|
|
"Domain semantics:": "domain",
|
|
"Required pattern:": "pattern",
|
|
"Layer context:": "layer",
|
|
}
|
|
|
|
|
|
def classify_load_label(label: str) -> str:
|
|
for prefix, kind in LOAD_LABEL_MAP.items():
|
|
if label.strip().startswith(prefix.rstrip(":")):
|
|
return kind
|
|
return "link"
|
|
|
|
|
|
MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]*)\)")
|
|
|
|
|
|
def parse_md_link(text: str) -> tuple[str, str] | None:
|
|
"""Return (label, target) or None."""
|
|
m = MD_LINK_RE.search(text)
|
|
if m:
|
|
return m.group(1), m.group(2)
|
|
return None
|
|
|
|
|
|
def parse_control_file(path: Path, root: Path) -> ControlFile:
|
|
"""Parse a control file, gracefully degrading on free-form files."""
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return ControlFile(path, posix_rel(path, root), None, None, None, [], [], [], None, [])
|
|
|
|
rel = posix_rel(path, root)
|
|
control_dir = path.parent
|
|
text = expand_includes(text, control_dir)
|
|
|
|
lines = text.splitlines()
|
|
|
|
title: str | None = None
|
|
area: str | None = None
|
|
knowledge: str | None = None
|
|
load_items: list[LoadItem] = []
|
|
warnings_list: list[str] = []
|
|
handoff_list: list[str] = []
|
|
first_para: str | None = None
|
|
section_order: list[str] = []
|
|
|
|
current_section: str | None = None
|
|
in_preamble = False
|
|
preamble_lines: list[str] = []
|
|
after_h1 = False
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
|
|
# H1
|
|
if stripped.startswith("# ") and not stripped.startswith("## "):
|
|
if title is None:
|
|
raw_title = stripped[2:].strip()
|
|
# Strip suffix after " — "
|
|
if " — " in raw_title:
|
|
raw_title = raw_title.split(" — ")[0].strip()
|
|
title = raw_title
|
|
in_preamble = True
|
|
after_h1 = True
|
|
current_section = None
|
|
continue
|
|
|
|
# H2 section headers
|
|
if stripped.startswith("## "):
|
|
section_name = stripped[3:].strip()
|
|
if in_preamble:
|
|
in_preamble = False
|
|
if section_name in CANONICAL_SECTIONS:
|
|
section_order.append(section_name)
|
|
current_section = section_name
|
|
else:
|
|
current_section = None
|
|
continue
|
|
|
|
# Deeper headers — end current section processing but track for H3+
|
|
if stripped.startswith("#"):
|
|
if in_preamble:
|
|
in_preamble = False
|
|
current_section = None
|
|
continue
|
|
|
|
# Preamble: Area: and Knowledge: lines
|
|
if in_preamble and after_h1:
|
|
if stripped.startswith("Area:"):
|
|
val = stripped[len("Area:"):].strip()
|
|
if val.startswith("`") and val.endswith("`"):
|
|
val = val[1:-1]
|
|
area = val if val else None
|
|
elif stripped.startswith("Knowledge:"):
|
|
val = stripped[len("Knowledge:"):].strip()
|
|
link = parse_md_link(val)
|
|
if link:
|
|
target = link[1]
|
|
# Resolve: /path = repo-root-relative, else relative to control_dir
|
|
if target.startswith("/"):
|
|
resolved = (root / target.lstrip("/")).resolve()
|
|
else:
|
|
resolved = (control_dir / target).resolve()
|
|
knowledge = posix_rel(resolved, root)
|
|
else:
|
|
knowledge = val if val else None
|
|
elif stripped and not stripped.startswith("#"):
|
|
preamble_lines.append(stripped)
|
|
|
|
# Section content
|
|
if current_section == "Load" and stripped.startswith("- "):
|
|
bullet = stripped[2:].strip()
|
|
link = parse_md_link(bullet)
|
|
if link:
|
|
label, target = link
|
|
kind = classify_load_label(label)
|
|
# Resolve target: /path = repo-root-relative, else relative to control_dir
|
|
if not target.startswith(("http://", "https://", "mailto:")):
|
|
if target.startswith("/"):
|
|
resolved = (root / target.lstrip("/")).resolve()
|
|
else:
|
|
resolved = (control_dir / target).resolve()
|
|
target_rel = posix_rel(resolved, root)
|
|
else:
|
|
target_rel = target
|
|
load_items.append(LoadItem(kind, label, target_rel, bullet))
|
|
else:
|
|
load_items.append(LoadItem("prose", "", None, bullet))
|
|
|
|
elif current_section == "Warnings" and stripped.startswith("- "):
|
|
warnings_list.append(stripped[2:].strip())
|
|
|
|
elif current_section == "Handoff" and stripped.startswith("- "):
|
|
handoff_list.append(stripped[2:].strip())
|
|
|
|
# Extract first paragraph from preamble
|
|
if preamble_lines:
|
|
# Join consecutive non-empty preamble lines into paragraph
|
|
para_parts = []
|
|
for pl in preamble_lines:
|
|
if pl:
|
|
para_parts.append(pl)
|
|
else:
|
|
break
|
|
if para_parts:
|
|
first_para = " ".join(para_parts)
|
|
|
|
return ControlFile(path, rel, title, area, knowledge, load_items, warnings_list, handoff_list, first_para, section_order)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wiki file helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def read_wiki_file(path: Path) -> tuple[FrontMatter, str]:
|
|
"""Read a wiki file, returning parsed front-matter and body."""
|
|
if not path.exists():
|
|
return FrontMatter(None, [], "", False), ""
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
return parse_frontmatter(text)
|
|
|
|
|
|
def write_generated_file(path: Path, content: str, status: str = "generated",
|
|
source_paths: list[str] | None = None):
|
|
"""Write a generated file with front-matter, only if status allows.
|
|
|
|
Returns:
|
|
True = file was created or updated
|
|
False = file was skipped (authored/stub or identical content)
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
full_content = render_frontmatter_block(status, source_paths or []) + "\n" + content
|
|
|
|
# If file exists, check status and content
|
|
if path.exists():
|
|
existing = path.read_text(encoding="utf-8", errors="replace")
|
|
fm, _ = parse_frontmatter(existing)
|
|
if fm.present and fm.status in ("authored", "stub"):
|
|
return False # never clobber authored/stub
|
|
if existing == full_content:
|
|
return False # already up to date; report as skipped
|
|
|
|
path.write_text(full_content, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def file_is_generated(path: Path) -> bool:
|
|
"""Return True if file has status: generated front-matter."""
|
|
if not path.exists():
|
|
return False
|
|
fm, _ = parse_frontmatter(path.read_text(encoding="utf-8", errors="replace"))
|
|
return fm.present and fm.status == "generated"
|
|
|
|
|
|
def find_wiki_docs_for_path(target_dir: Path, root: Path, wiki_root: Path) -> list[dict]:
|
|
"""Find wiki documents relevant to a source path.
|
|
|
|
Searches:
|
|
1. Codebase mirror: wiki/codebase/<rel>/ — README.md, NAV.md, DOMAIN.md, any .md
|
|
2. All wiki branches: scan wiki/<branch>/ trees for docs whose source-paths
|
|
include the target (or an ancestor of it).
|
|
3. Knowledge: links from the target's control file.
|
|
|
|
Returns list of dicts: {path, label, kind, status}
|
|
"""
|
|
target_rel = posix_rel(target_dir, root)
|
|
results = []
|
|
seen_paths: set[str] = set()
|
|
|
|
def add_doc(doc_path: Path, label: str, kind: str):
|
|
p = posix_rel(doc_path, root)
|
|
if p not in seen_paths and doc_path.exists():
|
|
fm, _ = read_wiki_file(doc_path)
|
|
results.append({
|
|
"path": p,
|
|
"label": label,
|
|
"kind": kind,
|
|
"status": fm.status or "unknown",
|
|
})
|
|
seen_paths.add(p)
|
|
|
|
# 1. Codebase mirror — walk up from target to root looking for mirror docs
|
|
check_dir = target_dir
|
|
while True:
|
|
rel = posix_rel(check_dir, root)
|
|
mirror_dir = wiki_root / CODEBASE_SUBDIR / rel if rel and rel != "." else wiki_root / CODEBASE_SUBDIR
|
|
if mirror_dir.exists():
|
|
for md_file in sorted(mirror_dir.glob("*.md")):
|
|
name = md_file.stem.upper()
|
|
if name == "README":
|
|
add_doc(md_file, "Code orientation", "wiki_readme")
|
|
elif name == "NAV":
|
|
add_doc(md_file, "Local navigation", "wiki_nav")
|
|
elif name == "DOMAIN":
|
|
add_doc(md_file, "Domain overview", "wiki_domain")
|
|
else:
|
|
add_doc(md_file, md_file.stem, "wiki_doc")
|
|
if any(mirror_dir.glob("*.md")):
|
|
break # found the nearest mirror with content
|
|
if check_dir == root:
|
|
break
|
|
parent = check_dir.parent
|
|
if parent == check_dir:
|
|
break
|
|
check_dir = parent
|
|
|
|
# 2. Scan non-codebase wiki branches for docs with matching source-paths
|
|
if wiki_root.exists():
|
|
for branch_dir in sorted(wiki_root.iterdir()):
|
|
if not branch_dir.is_dir():
|
|
continue
|
|
if branch_dir.name == CODEBASE_SUBDIR:
|
|
continue # already handled above
|
|
if branch_dir.name.startswith("."):
|
|
continue
|
|
# Walk this branch for .md files with source-paths that match target
|
|
for md_file in branch_dir.rglob("*.md"):
|
|
fm, _ = read_wiki_file(md_file)
|
|
if not fm.source_paths:
|
|
continue
|
|
for sp in fm.source_paths:
|
|
sp_normalized = sp.rstrip("/")
|
|
target_normalized = target_rel.rstrip("/")
|
|
# Match if source-path is the target, an ancestor, or a child
|
|
if (sp_normalized == target_normalized
|
|
or target_normalized.startswith(sp_normalized + "/")
|
|
or sp_normalized.startswith(target_normalized + "/")):
|
|
name = md_file.stem.upper()
|
|
branch = branch_dir.name
|
|
if name == "README":
|
|
add_doc(md_file, f"Domain knowledge ({branch})", "domain_readme")
|
|
elif name == "NAV":
|
|
add_doc(md_file, f"Navigation ({branch})", "domain_nav")
|
|
else:
|
|
add_doc(md_file, f"{md_file.stem} ({branch})", "domain_doc")
|
|
break # don't add same file twice for multiple source-paths
|
|
|
|
# 3. Knowledge: link from control file
|
|
cf = find_control_file(target_dir)
|
|
if cf:
|
|
parsed = parse_control_file(cf, root)
|
|
if parsed.knowledge:
|
|
knowledge_path = root / parsed.knowledge
|
|
if knowledge_path.is_dir():
|
|
# Look for README.md or index in the knowledge dir
|
|
for candidate in ["README.md", "readme.md", "NAV.md"]:
|
|
kf = knowledge_path / candidate
|
|
if kf.exists():
|
|
add_doc(kf, "Knowledge link", "knowledge_ref")
|
|
elif knowledge_path.exists():
|
|
add_doc(knowledge_path, "Knowledge link", "knowledge_ref")
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Justfile parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_justfile(root: Path) -> list[tuple[str, str]]:
|
|
"""
|
|
Parse a Justfile and return list of (recipe_name, comment) pairs.
|
|
Comments are the # line immediately preceding a recipe.
|
|
"""
|
|
justfile = root / "Justfile"
|
|
if not justfile.exists():
|
|
return []
|
|
|
|
try:
|
|
text = justfile.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return []
|
|
|
|
recipes = []
|
|
lines = text.splitlines()
|
|
last_comment = ""
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("#"):
|
|
last_comment = stripped[1:].strip()
|
|
elif stripped and not stripped.startswith(" ") and not stripped.startswith("\t"):
|
|
# Possible recipe line: name [params]:
|
|
m = re.match(r"^([a-zA-Z0-9_-]+)(?:\s+[^:]+)?:", stripped)
|
|
if m and not stripped.startswith("set ") and not stripped.startswith("export"):
|
|
recipe_name = m.group(1)
|
|
if recipe_name not in ("default",):
|
|
recipes.append((recipe_name, last_comment))
|
|
last_comment = ""
|
|
else:
|
|
last_comment = ""
|
|
else:
|
|
if not stripped:
|
|
last_comment = ""
|
|
|
|
return recipes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Toolchain detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def detect_toolchains(root: Path) -> dict[str, Any]:
|
|
"""Detect toolchains from manifests and files."""
|
|
toolchains = {}
|
|
|
|
if (root / "Cargo.toml").exists():
|
|
toolchains["cargo"] = True
|
|
# Read rust edition / toolchain
|
|
rust_toolchain = root / "rust-toolchain.toml"
|
|
if rust_toolchain.exists():
|
|
try:
|
|
t = rust_toolchain.read_text(encoding="utf-8")
|
|
m = re.search(r'channel\s*=\s*"([^"]+)"', t)
|
|
if m:
|
|
toolchains["rust_channel"] = m.group(1)
|
|
except OSError:
|
|
pass
|
|
|
|
if (root / "scripts" / "pyproject.toml").exists():
|
|
toolchains["uv"] = True
|
|
toolchains["uv_project"] = "scripts"
|
|
|
|
if (root / "pyproject.toml").exists():
|
|
toolchains["uv"] = True
|
|
|
|
if (root / "package.json").exists():
|
|
toolchains["node"] = True
|
|
|
|
if (root / "Justfile").exists():
|
|
toolchains["just"] = True
|
|
|
|
return toolchains
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Navigation table generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
|
|
|
|
|
def sanitize_purpose(text: str) -> str:
|
|
"""Make text safe for a Markdown table Purpose cell.
|
|
|
|
Control-file paragraphs can contain Markdown links, pipes, and newlines.
|
|
Embedding those verbatim corrupts the table and creates false broken-link
|
|
findings (the links resolve relative to the wiki file, not the source file).
|
|
Flatten links to their label, escape pipes, collapse whitespace, truncate.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
text = _MD_LINK_RE.sub(r"\1", text) # [label](url) -> label
|
|
text = " ".join(text.split()) # collapse whitespace/newlines
|
|
text = text.replace("|", "\\|") # don't break table columns
|
|
if len(text) > 100:
|
|
text = text[:100].rstrip() + "..."
|
|
return text
|
|
|
|
|
|
def get_dir_purpose(directory: Path, root: Path) -> str:
|
|
"""
|
|
Return a purpose string for a directory.
|
|
Source: Area: or Knowledge: label in control file, else H1/first_para, else "".
|
|
NEVER invent from dir name. Output is sanitized for table cells.
|
|
"""
|
|
cf = find_control_file(directory)
|
|
if cf:
|
|
parsed = parse_control_file(cf, root)
|
|
if parsed.area:
|
|
return sanitize_purpose(parsed.area)
|
|
if parsed.knowledge:
|
|
return sanitize_purpose(parsed.knowledge)
|
|
if parsed.first_para:
|
|
return sanitize_purpose(parsed.first_para)
|
|
if parsed.title:
|
|
return sanitize_purpose(parsed.title)
|
|
return ""
|
|
|
|
|
|
def generate_nav_table(dirs: list[Path], root: Path, wiki_root: Path,
|
|
nav_from: Path, *, include_empty: bool = False) -> str:
|
|
"""
|
|
Generate a markdown nav table using repo-root-relative /path links.
|
|
nav_from: the file that will contain this table (kept for signature compat).
|
|
include_empty: if False, skip rows with no purpose AND no knowledge link.
|
|
"""
|
|
rows = []
|
|
for d in dirs:
|
|
rel = posix_rel(d, root)
|
|
purpose = get_dir_purpose(d, root)
|
|
# Local control link — repo-root-relative /path
|
|
cf = find_control_file(d)
|
|
if cf:
|
|
cf_repo_rel = posix_rel(cf, root)
|
|
cf_cell = f"[{cf.name}](/{cf_repo_rel})"
|
|
else:
|
|
cf_cell = "—"
|
|
|
|
# Knowledge link: wiki/codebase/<rel>/ if exists — repo-root-relative /path
|
|
wiki_dir = wiki_root / CODEBASE_SUBDIR / rel
|
|
if wiki_dir.exists() and any(wiki_dir.glob("*.md")):
|
|
wiki_repo_rel = posix_rel(wiki_dir, root)
|
|
knowledge_cell = f"[wiki](/{wiki_repo_rel}/)"
|
|
else:
|
|
knowledge_cell = "—"
|
|
|
|
# Skip rows with no useful content unless include_empty is True
|
|
if not include_empty and not purpose and knowledge_cell == "—":
|
|
continue
|
|
|
|
rows.append((f"`{rel}/`", purpose, cf_cell, knowledge_cell))
|
|
|
|
lines = [
|
|
"| Path | Purpose | Local control | Knowledge |",
|
|
"|------|---------|---------------|-----------|",
|
|
]
|
|
for path_cell, purpose, cf_cell, knowledge_cell in rows:
|
|
lines.append(f"| {path_cell} | {purpose} | {cf_cell} | {knowledge_cell} |")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content generators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_boot_md(root: Path) -> str:
|
|
"""Generate content for boot.md (without front-matter)."""
|
|
sections = []
|
|
|
|
# --- What This Project Is ---
|
|
readme = root / "README.md"
|
|
project_name = root.name
|
|
project_desc = ""
|
|
if readme.exists():
|
|
try:
|
|
rtext = readme.read_text(encoding="utf-8", errors="replace")
|
|
rlines = rtext.splitlines()
|
|
for line in rlines:
|
|
s = line.strip()
|
|
if s.startswith("# "):
|
|
project_name = s[2:].strip()
|
|
break
|
|
# First non-empty paragraph after H1
|
|
found_h1 = False
|
|
para_lines = []
|
|
for line in rlines:
|
|
s = line.strip()
|
|
if not found_h1:
|
|
if s.startswith("# "):
|
|
found_h1 = True
|
|
continue
|
|
if s.startswith("#"):
|
|
break
|
|
if s:
|
|
para_lines.append(s)
|
|
elif para_lines:
|
|
break
|
|
if para_lines:
|
|
project_desc = " ".join(para_lines)
|
|
except OSError:
|
|
pass
|
|
|
|
what_section = f"## What This Project Is\n\n**{project_name}**"
|
|
if project_desc:
|
|
what_section += f"\n\n{project_desc}"
|
|
sections.append(what_section)
|
|
|
|
# --- Toolchains ---
|
|
toolchains = detect_toolchains(root)
|
|
tc_lines = ["## Toolchains", ""]
|
|
if toolchains.get("cargo"):
|
|
channel = toolchains.get("rust_channel", "stable")
|
|
tc_lines.append(f"- **Rust / Cargo** — `{channel}` channel")
|
|
if toolchains.get("uv"):
|
|
uv_proj = toolchains.get("uv_project", "scripts")
|
|
tc_lines.append(f"- **Python / uv** — project: `{uv_proj}/`")
|
|
if toolchains.get("node"):
|
|
tc_lines.append("- **Node.js / npm** — `package.json` present")
|
|
if toolchains.get("just"):
|
|
tc_lines.append("- **Just** — task runner (`just --list` for all recipes)")
|
|
sections.append("\n".join(tc_lines))
|
|
|
|
# --- Key Folders ---
|
|
dirs = top_level_dirs(root)
|
|
folder_rows = []
|
|
for d in dirs:
|
|
purpose = get_dir_purpose(d, root)
|
|
if purpose:
|
|
folder_rows.append(f"| `{d.name}/` | {purpose} |")
|
|
folder_lines = ["## Key Folders", ""]
|
|
if folder_rows:
|
|
folder_lines.extend(["| Folder | Purpose |", "|--------|---------|"])
|
|
folder_lines.extend(folder_rows)
|
|
folder_lines.append("")
|
|
folder_lines.append("Run `ls` (or check `wiki/codebase/NAV.md`) for the full directory listing.")
|
|
else:
|
|
folder_lines.append("No folder descriptions available yet. Run `ls` to see the directory structure.")
|
|
sections.append("\n".join(folder_lines))
|
|
|
|
# --- Essential Commands ---
|
|
recipes = parse_justfile(root)
|
|
cmd_lines = ["## Essential Commands", ""]
|
|
|
|
if toolchains.get("just") and recipes:
|
|
essential = {"run", "dev", "server", "check", "build", "test", "test-net", "test-one"}
|
|
cmd_lines.append("**Core recipes** (run via `just <recipe>`):")
|
|
cmd_lines.append("")
|
|
for name, comment in recipes:
|
|
if name in essential:
|
|
if comment:
|
|
cmd_lines.append(f"- `just {name}` — {comment}")
|
|
else:
|
|
cmd_lines.append(f"- `just {name}`")
|
|
cmd_lines.append("")
|
|
cmd_lines.append("Run `just --list` for the full set.")
|
|
cmd_lines.append("")
|
|
|
|
if toolchains.get("cargo"):
|
|
cmd_lines.append("**Cargo** (Rust build system):")
|
|
cmd_lines.append("")
|
|
cmd_lines.append("- `cargo check --workspace` — type-check all crates")
|
|
cmd_lines.append("- `cargo test --workspace` — run all tests")
|
|
cmd_lines.append("- `cargo build --workspace` — build all crates")
|
|
cmd_lines.append("")
|
|
|
|
if toolchains.get("uv"):
|
|
uv_proj = toolchains.get("uv_project", "scripts")
|
|
cmd_lines.append("**Python scripts** (via uv):")
|
|
cmd_lines.append("")
|
|
cmd_lines.append(f"- `uv run --project {uv_proj} scripts/<name>.py` — run a Python script")
|
|
cmd_lines.append("")
|
|
|
|
sections.append("\n".join(cmd_lines))
|
|
|
|
# --- Where the Knowledge Layer Lives ---
|
|
kl_section = (
|
|
"## Where the Knowledge Layer Lives\n\n"
|
|
"- `wiki/` — Authored and generated wiki documents (navigation, concepts, patterns)\n"
|
|
"- `wiki/boot.md` — This file (project identity and orientation)\n\n"
|
|
"Navigate the wiki via `wiki/NAV.md` or `wiki/codebase/NAV.md`.\n"
|
|
"See the root `CLAUDE.md` for full project guidelines, conventions, and platform notes."
|
|
)
|
|
sections.append(kl_section)
|
|
|
|
# --- Where Executable Truth Lives ---
|
|
et_section = (
|
|
"## Where Executable Truth Lives\n\n"
|
|
"Source code is authoritative for current behavior. "
|
|
"Wiki documents describe concepts and provide navigation — they may lag implementation.\n\n"
|
|
"Always inspect source before making claims about current executable behavior."
|
|
)
|
|
sections.append(et_section)
|
|
|
|
# --- Critical Universal Warnings ---
|
|
root_cf = find_control_file(root)
|
|
universal_warnings = []
|
|
if root_cf:
|
|
parsed = parse_control_file(root_cf, root)
|
|
universal_warnings = parsed.warnings
|
|
|
|
warn_section = "## Critical Universal Warnings\n\n"
|
|
if universal_warnings:
|
|
warn_section += "\n".join(f"- {w}" for w in universal_warnings)
|
|
else:
|
|
warn_section += "(none — see root CLAUDE.md for full project guidelines)"
|
|
sections.append(warn_section)
|
|
|
|
body = "# Repository Boot\n\n" + "\n\n".join(sections)
|
|
return body
|
|
|
|
|
|
def generate_wiki_readme(root: Path, wiki_root: Path) -> str:
|
|
"""Generate wiki/README.md content (without front-matter)."""
|
|
return (
|
|
"# Wiki\n\n"
|
|
"This directory contains the knowledge layer for the codebase: "
|
|
"navigation maps, authored wiki documents, and generated resources.\n\n"
|
|
"| Section | Description |\n"
|
|
"|---------|-------------|\n"
|
|
"| [`codebase/`](/wiki/codebase/) | Codebase navigation — mirrors the source tree |\n\n"
|
|
"**Note**: Wiki documents describe concepts and provide navigation. "
|
|
"Source code is authoritative for current behavior.\n\n"
|
|
"See [`NAV.md`](/wiki/NAV.md) for a top-level navigation table."
|
|
)
|
|
|
|
|
|
def generate_wiki_nav(root: Path, wiki_root: Path) -> str:
|
|
"""Generate wiki/NAV.md content (without front-matter)."""
|
|
dirs = top_level_dirs(root)
|
|
nav_file = wiki_root / "NAV.md"
|
|
table = generate_nav_table(dirs, root, wiki_root, nav_file)
|
|
return (
|
|
"# Repository Navigation\n\n"
|
|
f"{table}\n\n"
|
|
"Only directories with known purpose or wiki coverage are listed. "
|
|
"Run `ls` for the complete directory listing."
|
|
)
|
|
|
|
|
|
def generate_wiki_codebase_nav(root: Path, wiki_root: Path) -> str:
|
|
"""Generate wiki/codebase/NAV.md content (without front-matter).
|
|
|
|
Maps every directory that holds a control file (the existing CLAUDE.md /
|
|
AGENTS.md structure), so the wiki is a real index of authored guidance from
|
|
day one. The repository root control file is excluded — it is covered by
|
|
boot.md. Directories are listed in repository order.
|
|
"""
|
|
cf_dirs = sorted(
|
|
{cf.parent for cf in find_all_control_files(root) if cf.parent != root},
|
|
key=lambda d: posix_rel(d, root),
|
|
)
|
|
nav_file = wiki_root / CODEBASE_SUBDIR / "NAV.md"
|
|
table = generate_nav_table(cf_dirs, root, wiki_root, nav_file)
|
|
intro = (
|
|
"Directories below carry a local control file (`CLAUDE.md` / `AGENTS.md`). "
|
|
"This is the existing authored guidance the wiki ingests and grows from. "
|
|
"Use `just tome context <path>` to resolve the full ancestor chain for any path."
|
|
)
|
|
return f"# Codebase Navigation\n\n{intro}\n\n{table}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inventory generation (gitignored, curation input)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_codebase_inventory(root: Path, wiki_root: Path) -> str:
|
|
"""Generate exhaustive codebase inventory for curation agents.
|
|
|
|
Lists every directory with a control file, plus metadata to help curation
|
|
agents decide what belongs in NAV.md without extra tool calls.
|
|
"""
|
|
cf_dirs = sorted(
|
|
{cf.parent for cf in find_all_control_files(root) if cf.parent != root},
|
|
key=lambda d: posix_rel(d, root),
|
|
)
|
|
|
|
lines = [
|
|
"# Codebase Inventory",
|
|
"",
|
|
"Generated by `knowledge.py refresh`. **Do not edit** — this file is gitignored",
|
|
"and regenerated on every refresh. Curation agents read this to discover what",
|
|
"exists; they write to `NAV.md` only what earns its place.",
|
|
"",
|
|
"| Path | Purpose | Control file | Wiki mirror | Wiki branches |",
|
|
"|------|---------|-------------|-------------|---------------|",
|
|
]
|
|
|
|
for d in cf_dirs:
|
|
rel = posix_rel(d, root)
|
|
purpose = get_dir_purpose(d, root)
|
|
|
|
# Control file info
|
|
cf = find_control_file(d)
|
|
cf_name = cf.name if cf else "—"
|
|
|
|
# Wiki codebase mirror
|
|
mirror_dir = wiki_root / CODEBASE_SUBDIR / rel
|
|
has_mirror = mirror_dir.exists() and any(mirror_dir.glob("*.md"))
|
|
mirror_cell = "yes" if has_mirror else "—"
|
|
|
|
# Non-codebase wiki branches that reference this path
|
|
branch_hits = []
|
|
if wiki_root.exists():
|
|
for branch_dir in sorted(wiki_root.iterdir()):
|
|
if not branch_dir.is_dir() or branch_dir.name == CODEBASE_SUBDIR:
|
|
continue
|
|
if branch_dir.name.startswith("."):
|
|
continue
|
|
for md_file in branch_dir.rglob("*.md"):
|
|
fm, _ = read_wiki_file(md_file)
|
|
for sp in fm.source_paths:
|
|
sp_n = sp.rstrip("/")
|
|
rel_n = rel.rstrip("/")
|
|
if sp_n == rel_n or rel_n.startswith(sp_n + "/") or sp_n.startswith(rel_n + "/"):
|
|
branch_hits.append(branch_dir.name)
|
|
break
|
|
else:
|
|
continue
|
|
break
|
|
branches_cell = ", ".join(sorted(set(branch_hits))) if branch_hits else "—"
|
|
|
|
lines.append(f"| `{rel}/` | {sanitize_purpose(purpose)} | {cf_name} | {mirror_cell} | {branches_cell} |")
|
|
|
|
lines.append("")
|
|
lines.append(f"Total: {len(cf_dirs)} directories with control files.")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def generate_wiki_inventory(root: Path, wiki_root: Path) -> str:
|
|
"""Generate top-level wiki inventory for curation agents.
|
|
|
|
Lists all wiki branches and their contents to help curators understand
|
|
wiki coverage without globbing.
|
|
"""
|
|
lines = [
|
|
"# Wiki Inventory",
|
|
"",
|
|
"Generated by `knowledge.py refresh`. **Do not edit** — this file is gitignored",
|
|
"and regenerated on every refresh.",
|
|
"",
|
|
]
|
|
|
|
if not wiki_root.exists():
|
|
lines.append("Wiki directory does not exist yet. Run `tome init` to bootstrap.")
|
|
return "\n".join(lines)
|
|
|
|
# List wiki branches
|
|
branches = []
|
|
for entry in sorted(wiki_root.iterdir()):
|
|
if not entry.is_dir() or entry.name.startswith("."):
|
|
continue
|
|
md_files = list(entry.rglob("*.md"))
|
|
authored = sum(1 for f in md_files if _file_status(f) == "authored")
|
|
generated = sum(1 for f in md_files if _file_status(f) == "generated")
|
|
stub = sum(1 for f in md_files if _file_status(f) == "stub")
|
|
branches.append((entry.name, len(md_files), authored, generated, stub))
|
|
|
|
if branches:
|
|
lines.append("## Branches")
|
|
lines.append("")
|
|
lines.append("| Branch | Files | Authored | Generated | Stub |")
|
|
lines.append("|--------|-------|----------|-----------|------|")
|
|
for name, total, auth, gen, stb in branches:
|
|
lines.append(f"| `{name}/` | {total} | {auth} | {gen} | {stb} |")
|
|
lines.append("")
|
|
|
|
# List top-level .md files
|
|
top_files = sorted(f for f in wiki_root.glob("*.md") if not f.name.startswith("."))
|
|
if top_files:
|
|
lines.append("## Top-level files")
|
|
lines.append("")
|
|
for f in top_files:
|
|
status = _file_status(f)
|
|
lines.append(f"- `{f.name}` ({status})")
|
|
lines.append("")
|
|
|
|
# Top-level source dirs without any wiki coverage
|
|
lines.append("## Uncovered source directories")
|
|
lines.append("")
|
|
lines.append("Top-level source directories with control files but no wiki branch coverage:")
|
|
lines.append("")
|
|
uncovered = []
|
|
for d in top_level_dirs(root):
|
|
if not find_control_file(d):
|
|
continue
|
|
rel = posix_rel(d, root)
|
|
mirror = wiki_root / CODEBASE_SUBDIR / rel
|
|
has_mirror = mirror.exists() and any(mirror.glob("*.md"))
|
|
if not has_mirror:
|
|
uncovered.append(rel)
|
|
if uncovered:
|
|
for rel in uncovered:
|
|
lines.append(f"- `{rel}/`")
|
|
else:
|
|
lines.append("(all covered)")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _file_status(path: Path) -> str:
|
|
"""Return the front-matter status of a wiki file, or 'unknown'."""
|
|
fm, _ = read_wiki_file(path)
|
|
return fm.status or "unknown"
|
|
|
|
|
|
def write_inventory_file(path: Path, content: str):
|
|
"""Write an inventory file (always overwritten, no front-matter)."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def seed_nav_stub(path: Path, title: str, intro: str) -> bool:
|
|
"""Create a NAV.md as status:stub if it doesn't exist. Never overwrites.
|
|
|
|
Returns True if created, False if already exists.
|
|
"""
|
|
if path.exists():
|
|
return False
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
content = render_frontmatter_block("stub", []) + "\n"
|
|
content += f"# {title}\n\n"
|
|
content += f"{intro}\n\n"
|
|
content += "<!-- Curate this file: read .inventory.md for the full listing, -->\n"
|
|
content += "<!-- then add only entries that earn their place here. -->\n\n"
|
|
content += "| Path | Why it matters |\n"
|
|
content += "|------|----------------|\n"
|
|
path.write_text(content, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bootstrap subcommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_bootstrap(root: Path) -> int:
|
|
"""Create missing scaffold files. Never clobber status:authored files."""
|
|
created = []
|
|
skipped = []
|
|
inventories = []
|
|
|
|
wiki_root = root / WIKI_SUBDIR
|
|
|
|
# 1. boot.md
|
|
boot_path = root / BOOT_MD_REL
|
|
boot_content = generate_boot_md(root)
|
|
if write_generated_file(boot_path, boot_content):
|
|
created.append(BOOT_MD_REL)
|
|
else:
|
|
skipped.append(BOOT_MD_REL)
|
|
|
|
# 2. wiki/README.md
|
|
wiki_readme_path = root / WIKI_README_REL
|
|
wiki_readme_content = generate_wiki_readme(root, wiki_root)
|
|
if write_generated_file(wiki_readme_path, wiki_readme_content):
|
|
created.append(WIKI_README_REL)
|
|
else:
|
|
skipped.append(WIKI_README_REL)
|
|
|
|
# 3. wiki/NAV.md — seed as stub if missing, never overwrite
|
|
wiki_nav_path = root / WIKI_NAV_REL
|
|
if seed_nav_stub(wiki_nav_path,
|
|
"Repository Navigation",
|
|
"Selective pointers to the most important areas. Not a complete listing — use `ls` for that."):
|
|
created.append(WIKI_NAV_REL)
|
|
else:
|
|
skipped.append(WIKI_NAV_REL)
|
|
|
|
# 4. wiki/codebase/NAV.md — seed as stub if missing, never overwrite
|
|
wiki_codebase_nav_path = root / WIKI_CODEBASE_NAV_REL
|
|
if seed_nav_stub(wiki_codebase_nav_path,
|
|
"Codebase Navigation",
|
|
"Key entry points into the source tree. Curated from `.inventory.md` — only directories that matter for orientation."):
|
|
created.append(WIKI_CODEBASE_NAV_REL)
|
|
else:
|
|
skipped.append(WIKI_CODEBASE_NAV_REL)
|
|
|
|
# 5. Inventory files (always regenerated, gitignored)
|
|
inv_path = root / WIKI_INVENTORY_REL
|
|
write_inventory_file(inv_path, generate_wiki_inventory(root, wiki_root))
|
|
inventories.append(WIKI_INVENTORY_REL)
|
|
|
|
cb_inv_path = root / WIKI_CODEBASE_INVENTORY_REL
|
|
write_inventory_file(cb_inv_path, generate_codebase_inventory(root, wiki_root))
|
|
inventories.append(WIKI_CODEBASE_INVENTORY_REL)
|
|
|
|
# Report conceptual knowledge: control files without wiki mirrors
|
|
control_files = find_all_control_files(root)
|
|
no_mirror = []
|
|
for cf in control_files:
|
|
cf_dir = cf.parent
|
|
rel = posix_rel(cf_dir, root)
|
|
wiki_mirror = wiki_root / CODEBASE_SUBDIR / rel
|
|
if not wiki_mirror.exists():
|
|
no_mirror.append(rel if rel else "(root)")
|
|
|
|
# Print report
|
|
print("=" * 60)
|
|
print("tome bootstrap report")
|
|
print("=" * 60)
|
|
print()
|
|
if created:
|
|
print("Created:")
|
|
for f in created:
|
|
print(f" + {f}")
|
|
print()
|
|
if skipped:
|
|
print("Skipped (already exist or status:authored/stub):")
|
|
for f in skipped:
|
|
print(f" ~ {f}")
|
|
print()
|
|
if inventories:
|
|
print("Inventories (gitignored, for curation agents):")
|
|
for f in inventories:
|
|
print(f" * {f}")
|
|
print()
|
|
if no_mirror:
|
|
print(f"Conceptual knowledge available for authoring later ({len(no_mirror)} dirs):")
|
|
for rel in sorted(no_mirror)[:20]: # cap display at 20
|
|
print(f" ? {rel}/CLAUDE.md -> wiki/codebase/{rel}/")
|
|
if len(no_mirror) > 20:
|
|
print(f" ... and {len(no_mirror) - 20} more")
|
|
print()
|
|
print(f"Control files found: {len(control_files)}")
|
|
print()
|
|
|
|
return EXIT_OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# refresh subcommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_refresh(root: Path) -> int:
|
|
"""Refresh status:generated files and inventories; report authored/stub for review."""
|
|
wiki_root = root / WIKI_SUBDIR
|
|
updated = []
|
|
inventories = []
|
|
proposed = []
|
|
|
|
# Files to auto-refresh if status:generated (NAV files excluded — they are
|
|
# now authored/stub and curated by agents, not regenerated by the tool)
|
|
generated_files = [
|
|
(root / BOOT_MD_REL, lambda: generate_boot_md(root), []),
|
|
]
|
|
|
|
for path, content_fn, src_paths in generated_files:
|
|
rel = posix_rel(path, root)
|
|
if not path.exists():
|
|
proposed.append(f"{rel} — missing (run bootstrap first)")
|
|
continue
|
|
if not file_is_generated(path):
|
|
proposed.append(f"{rel} — status is not 'generated'; skipped")
|
|
continue
|
|
new_content = content_fn()
|
|
full_content = render_frontmatter_block("generated", src_paths) + "\n" + new_content
|
|
existing = path.read_text(encoding="utf-8", errors="replace")
|
|
if existing != full_content:
|
|
path.write_text(full_content, encoding="utf-8")
|
|
updated.append(rel)
|
|
|
|
# Inventory files (always regenerated, gitignored)
|
|
inv_path = root / WIKI_INVENTORY_REL
|
|
write_inventory_file(inv_path, generate_wiki_inventory(root, wiki_root))
|
|
inventories.append(WIKI_INVENTORY_REL)
|
|
|
|
cb_inv_path = root / WIKI_CODEBASE_INVENTORY_REL
|
|
write_inventory_file(cb_inv_path, generate_codebase_inventory(root, wiki_root))
|
|
inventories.append(WIKI_CODEBASE_INVENTORY_REL)
|
|
|
|
# Scan control files
|
|
control_files = find_all_control_files(root)
|
|
dirs = top_level_dirs(root)
|
|
|
|
# Stale checks: wiki mirrors for removed source dirs
|
|
wiki_codebase = wiki_root / CODEBASE_SUBDIR
|
|
if wiki_codebase.exists():
|
|
for wiki_sub in sorted(wiki_codebase.iterdir()):
|
|
if wiki_sub.is_dir():
|
|
source_candidate = root / wiki_sub.name
|
|
if not source_candidate.exists():
|
|
proposed.append(
|
|
f"wiki/codebase/{wiki_sub.name}/ — "
|
|
f"source dir '{wiki_sub.name}/' no longer exists"
|
|
)
|
|
|
|
# New top-level dirs without wiki coverage
|
|
for d in dirs:
|
|
rel = posix_rel(d, root)
|
|
mirror = wiki_root / CODEBASE_SUBDIR / rel
|
|
if not mirror.exists() and find_control_file(d):
|
|
proposed.append(
|
|
f"{rel}/ — has CLAUDE.md but no wiki mirror (candidate for wiki/codebase/{rel}/)"
|
|
)
|
|
|
|
# NAV files that are still status:generated (legacy — should be curated)
|
|
for nav_rel in [WIKI_NAV_REL, WIKI_CODEBASE_NAV_REL]:
|
|
nav_path = root / nav_rel
|
|
if nav_path.exists() and file_is_generated(nav_path):
|
|
proposed.append(
|
|
f"{nav_rel} — still status:generated (should be curated to status:authored)"
|
|
)
|
|
|
|
# Print report
|
|
print("=" * 60)
|
|
print("tome refresh report")
|
|
print("=" * 60)
|
|
print()
|
|
if updated:
|
|
print("Automatically updated:")
|
|
for f in updated:
|
|
print(f" * {f}")
|
|
print()
|
|
else:
|
|
print("Automatically updated: (none — all generated files are up to date)")
|
|
print()
|
|
|
|
if inventories:
|
|
print("Inventories regenerated (gitignored, for curation agents):")
|
|
for f in inventories:
|
|
print(f" * {f}")
|
|
print()
|
|
|
|
if proposed:
|
|
print("Proposed for review (not automatically changed):")
|
|
for msg in proposed:
|
|
print(f" ? {msg}")
|
|
print()
|
|
|
|
print(f"Control-file inventory: {len(control_files)} files found")
|
|
print()
|
|
|
|
return EXIT_OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# context subcommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Finding(NamedTuple):
|
|
id: str
|
|
severity: str # error / warning
|
|
path: str
|
|
message: str
|
|
target: str = ""
|
|
|
|
|
|
def cmd_context(root: Path, target_str: str, as_json: bool = False) -> int:
|
|
"""Resolve navigation context for a target path."""
|
|
# 1. Normalize path. Relative paths are repository-relative (the documented
|
|
# contract), not cwd-relative — the tool is usually invoked from outside
|
|
# the repo root (e.g. the `just tome` module runs from the plugin dir).
|
|
target = Path(target_str)
|
|
if not target.is_absolute():
|
|
target = root / target
|
|
target = target.resolve()
|
|
|
|
# Check outside repo
|
|
try:
|
|
target.relative_to(root)
|
|
except ValueError:
|
|
msg = f"Path '{target_str}' is outside the repository root '{root}'"
|
|
if as_json:
|
|
print(json.dumps({"error": msg, "exit_code": EXIT_USAGE}))
|
|
else:
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
return EXIT_USAGE
|
|
|
|
# Check exists
|
|
if not target.exists():
|
|
msg = f"Path '{target_str}' does not exist"
|
|
if as_json:
|
|
print(json.dumps({"error": msg, "exit_code": EXIT_USAGE}))
|
|
else:
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
return EXIT_USAGE
|
|
|
|
target_rel = posix_rel(target, root)
|
|
target_kind = "directory" if target.is_dir() else "file"
|
|
|
|
# 2. Target dir
|
|
target_dir = target if target.is_dir() else target.parent
|
|
|
|
# 3. Enumerate ancestor dirs root -> target (inclusive)
|
|
ancestors: list[Path] = []
|
|
current = target_dir
|
|
while True:
|
|
ancestors.append(current)
|
|
if current == root:
|
|
break
|
|
parent = current.parent
|
|
if parent == current:
|
|
break
|
|
current = parent
|
|
ancestors.reverse() # root first
|
|
|
|
# 4. Per ancestor: pick effective control file
|
|
effective_controls: list[dict] = []
|
|
findings: list[Finding] = []
|
|
|
|
all_warnings: list[dict] = []
|
|
all_handoff: list[dict] = []
|
|
all_load: list[dict] = []
|
|
|
|
for anc in ancestors:
|
|
anc_rel = posix_rel(anc, root)
|
|
# Root is represented as "" not "."
|
|
if anc_rel == ".":
|
|
anc_rel = ""
|
|
cf = find_control_file(anc)
|
|
if cf is None:
|
|
continue
|
|
|
|
cf_rel = posix_rel(cf, root)
|
|
effective_controls.append({"path": cf_rel, "ancestor": anc_rel})
|
|
|
|
# 5. Parse
|
|
parsed = parse_control_file(cf, root)
|
|
|
|
# Aggregate warnings
|
|
for w in parsed.warnings:
|
|
all_warnings.append({"text": w, "source": cf_rel})
|
|
|
|
# Aggregate handoff
|
|
for h in parsed.handoff:
|
|
all_handoff.append({"text": h, "source": cf_rel})
|
|
|
|
# Aggregate load items
|
|
for item in parsed.load_items:
|
|
if item.target:
|
|
target_path = root / item.target
|
|
if not target_path.exists() and not item.target.startswith(("http://", "https://")):
|
|
findings.append(Finding(
|
|
"control.load-link.broken", "warning", cf_rel,
|
|
f"Load link target not found: '{item.target}'",
|
|
item.target
|
|
))
|
|
all_load.append({
|
|
"label": item.label,
|
|
"kind": item.kind,
|
|
"target": item.target,
|
|
"source": cf_rel,
|
|
})
|
|
|
|
# 7. Wiki document discovery
|
|
wiki_root = root / WIKI_SUBDIR
|
|
wiki_docs = find_wiki_docs_for_path(target_dir, root, wiki_root)
|
|
|
|
# Mirror info for backward compat (JSON output)
|
|
mirror_info: dict | None = None
|
|
if wiki_docs:
|
|
# First codebase mirror doc determines the mirror path
|
|
for wd in wiki_docs:
|
|
if wd["kind"].startswith("wiki_"):
|
|
mirror_path = str(PurePosixPath(wd["path"]).parent)
|
|
mirror_info = {
|
|
"path": mirror_path,
|
|
"documents": wiki_docs,
|
|
}
|
|
break
|
|
if not mirror_info:
|
|
mirror_info = {"path": "", "documents": wiki_docs}
|
|
|
|
# 8. Required reading (resolvable Load link targets + wiki docs)
|
|
required_reading = []
|
|
for item in all_load:
|
|
if item["target"] and not item["target"].startswith(("http://", "https://")):
|
|
t_path = root / item["target"]
|
|
if t_path.exists():
|
|
required_reading.append({
|
|
"path": item["target"],
|
|
"label": item["label"],
|
|
"source": item["source"],
|
|
"kind": item["kind"],
|
|
})
|
|
|
|
# Add wiki documents to required reading — authored docs first, then generated
|
|
for wd in wiki_docs:
|
|
if not any(r["path"] == wd["path"] for r in required_reading):
|
|
required_reading.append({
|
|
"path": wd["path"],
|
|
"label": wd["label"],
|
|
"source": "wiki",
|
|
"kind": wd["kind"],
|
|
})
|
|
|
|
# 9. Quick top commands from Justfile
|
|
recipes = parse_justfile(root)
|
|
top_commands = []
|
|
important_recipes = ["check", "test", "build", "run", "dev", "tome-check", "tome-context"]
|
|
for name, comment in recipes:
|
|
if name in important_recipes:
|
|
top_commands.append(f"just {name}")
|
|
if not top_commands and recipes:
|
|
top_commands = [f"just {recipes[0][0]}"]
|
|
|
|
# Sort findings
|
|
findings.sort(key=lambda f: (0 if f.severity == "error" else 1, f.path))
|
|
|
|
if as_json:
|
|
# Enrich required_reading and effective_controls with root_relative_path (/ prefix)
|
|
rr_enriched = [
|
|
{**r, "root_relative_path": f"/{r['path']}"}
|
|
for r in required_reading
|
|
]
|
|
ec_enriched = [
|
|
{**ec, "root_relative_path": f"/{ec['path']}"}
|
|
for ec in effective_controls
|
|
]
|
|
output = {
|
|
"schema_version": 2,
|
|
"repository_root": root.as_posix(),
|
|
"target": target_rel,
|
|
"target_kind": target_kind,
|
|
"effective_controls": ec_enriched,
|
|
"warnings": all_warnings,
|
|
"handoff": all_handoff,
|
|
"required_reading": rr_enriched,
|
|
"mirror": mirror_info,
|
|
"top_commands": top_commands,
|
|
"findings": [
|
|
{
|
|
"id": f.id,
|
|
"severity": f.severity,
|
|
"path": f.path,
|
|
"message": f.message,
|
|
"target": f.target,
|
|
}
|
|
for f in findings
|
|
],
|
|
}
|
|
print(json.dumps(output, indent=2))
|
|
else:
|
|
# Human output
|
|
print(f"Context: {target_rel} ({target_kind})")
|
|
print()
|
|
|
|
print("Warnings:")
|
|
if all_warnings:
|
|
for w in all_warnings:
|
|
print(f" - {w['text']} [from {w['source']}]")
|
|
else:
|
|
print(" (none)")
|
|
print()
|
|
|
|
print("Read first:")
|
|
if required_reading:
|
|
for r in required_reading:
|
|
status_hint = ""
|
|
# Show authored wiki docs prominently
|
|
if r["source"] == "wiki":
|
|
wd_match = next((w for w in wiki_docs if w["path"] == r["path"]), None)
|
|
if wd_match and wd_match["status"] == "authored":
|
|
status_hint = " (authored)"
|
|
elif wd_match and wd_match["status"] == "generated":
|
|
status_hint = " (generated)"
|
|
print(f" /{r['path']}{status_hint} — {r['label']}")
|
|
else:
|
|
print(" (none)")
|
|
print()
|
|
|
|
if wiki_docs:
|
|
print("Wiki knowledge:")
|
|
for wd in wiki_docs:
|
|
print(f" /{wd['path']} [{wd['status']}] — {wd['label']}")
|
|
print(" Use `ls` to verify current directory contents — wiki may lag.")
|
|
else:
|
|
print("Wiki knowledge: (none — no wiki documents cover this path yet)")
|
|
print()
|
|
|
|
print("Useful commands:")
|
|
if top_commands:
|
|
for cmd in top_commands:
|
|
print(f" {cmd}")
|
|
else:
|
|
print(" (run `just --list` for available recipes)")
|
|
print()
|
|
|
|
print("Relevant control files:")
|
|
if effective_controls:
|
|
for ec in effective_controls:
|
|
print(f" /{ec['path']}")
|
|
else:
|
|
print(" (none)")
|
|
print()
|
|
|
|
if findings:
|
|
print("Findings:")
|
|
for f in findings:
|
|
print(f" [{f.severity.upper()}] {f.id}: {f.message} ({f.path})")
|
|
print()
|
|
|
|
return EXIT_OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check subcommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def strip_fenced_code(text: str) -> str:
|
|
"""Return a version of text with fenced code block contents replaced by blank lines."""
|
|
lines = text.splitlines(keepends=True)
|
|
result = []
|
|
in_fence = False
|
|
fence_marker = ""
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not in_fence:
|
|
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
in_fence = True
|
|
fence_marker = stripped[:3]
|
|
result.append(line) # keep the fence line itself
|
|
else:
|
|
result.append(line)
|
|
else:
|
|
if stripped.startswith(fence_marker) and stripped == fence_marker:
|
|
in_fence = False
|
|
result.append(line)
|
|
else:
|
|
# Replace with blank line to preserve line numbers
|
|
result.append("\n")
|
|
return "".join(result)
|
|
|
|
|
|
def count_h1(text: str) -> int:
|
|
"""Count H1 headings in markdown text, skipping fenced code blocks."""
|
|
clean = strip_fenced_code(text)
|
|
count = 0
|
|
for line in clean.splitlines():
|
|
s = line.strip()
|
|
if s.startswith("# ") and not s.startswith("## "):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def resolve_link_target(target: str, file_dir: Path, root: Path) -> Path:
|
|
"""Resolve a markdown link target to an absolute Path.
|
|
|
|
Resolution rules:
|
|
- ``/path`` — repo-root-relative (strip leading ``/``, join with ``root``)
|
|
- ``http://`` / ``https://`` — external (caller must skip before calling this)
|
|
- anything else — relative to ``file_dir`` (existing behaviour)
|
|
"""
|
|
if target.startswith("/"):
|
|
return (root / target.lstrip("/")).resolve()
|
|
return (file_dir / target).resolve()
|
|
|
|
|
|
def check_markdown_links(path: Path, root: Path) -> list[Finding]:
|
|
"""Check local markdown links in a file, skipping fenced code blocks."""
|
|
findings = []
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return findings
|
|
|
|
# Strip code blocks so we don't check links inside them
|
|
text = strip_fenced_code(text)
|
|
|
|
file_dir = path.parent
|
|
# Find all markdown links
|
|
for m in MD_LINK_RE.finditer(text):
|
|
target = m.group(2).strip()
|
|
# Skip external links, mailto, anchors-only
|
|
if target.startswith(("http://", "https://", "mailto:", "#")):
|
|
continue
|
|
# Strip fragment
|
|
if "#" in target:
|
|
target = target[:target.index("#")]
|
|
if not target:
|
|
continue
|
|
# Resolve: /path = repo-root-relative, else relative to file dir
|
|
resolved = resolve_link_target(target, file_dir, root)
|
|
if not resolved.exists():
|
|
rel_path = posix_rel(path, root)
|
|
findings.append(Finding(
|
|
"link.broken", "error", rel_path,
|
|
f"Broken link target: '{target}'",
|
|
target
|
|
))
|
|
return findings
|
|
|
|
|
|
def cmd_check(root: Path, as_json: bool = False) -> int:
|
|
"""Validate knowledge-layer health."""
|
|
findings: list[Finding] = []
|
|
wiki_root = root / WIKI_SUBDIR
|
|
|
|
# --- Check nav resources ---
|
|
boot_path = root / BOOT_MD_REL
|
|
|
|
if not boot_path.exists():
|
|
findings.append(Finding(
|
|
"nav.resource.missing", "error",
|
|
BOOT_MD_REL,
|
|
"boot.md is missing — run 'knowledge.py bootstrap' to create it",
|
|
))
|
|
|
|
wiki_readme = root / WIKI_README_REL
|
|
wiki_nav = root / WIKI_NAV_REL
|
|
if not wiki_readme.exists():
|
|
findings.append(Finding(
|
|
"nav.wiki.missing", "warning",
|
|
WIKI_README_REL,
|
|
"wiki/README.md is missing — run 'knowledge.py bootstrap'",
|
|
))
|
|
if not wiki_nav.exists():
|
|
findings.append(Finding(
|
|
"nav.wiki.missing", "warning",
|
|
WIKI_NAV_REL,
|
|
"wiki/NAV.md is missing — run 'knowledge.py bootstrap'",
|
|
))
|
|
|
|
# --- Check control files ---
|
|
control_files = find_all_control_files(root)
|
|
checked_dirs: set[Path] = set()
|
|
|
|
for cf in control_files:
|
|
cf_dir = cf.parent
|
|
rel = posix_rel(cf, root)
|
|
|
|
# coexist check — the @AGENTS.md router pattern (CLAUDE.md that is a
|
|
# pure include of AGENTS.md) is a recommended convention, not a clash
|
|
if cf_dir not in checked_dirs:
|
|
checked_dirs.add(cf_dir)
|
|
if has_both_control_files(cf_dir):
|
|
if not (is_pure_include(cf_dir / "CLAUDE.md") or is_pure_include(cf_dir / "AGENTS.md")):
|
|
findings.append(Finding(
|
|
"control.coexist", "warning",
|
|
posix_rel(cf_dir, root),
|
|
"Both CLAUDE.md and AGENTS.md exist in this directory",
|
|
))
|
|
|
|
# Pure-include routers delegate all content; the included file is
|
|
# checked on its own, so skip structural checks here
|
|
if is_pure_include(cf):
|
|
continue
|
|
|
|
# Parse
|
|
parsed = parse_control_file(cf, root)
|
|
|
|
# H1 checks (on include-expanded text, matching how agents see the file)
|
|
try:
|
|
text = expand_includes(cf.read_text(encoding="utf-8", errors="replace"), cf_dir)
|
|
except OSError:
|
|
continue
|
|
|
|
h1_count = count_h1(text)
|
|
if h1_count == 0:
|
|
findings.append(Finding(
|
|
"control.h1.missing", "error", rel,
|
|
"Control file has no H1 heading",
|
|
))
|
|
elif h1_count > 1:
|
|
findings.append(Finding(
|
|
"control.h1.multiple", "error", rel,
|
|
f"Control file has {h1_count} H1 headings (expected 1)",
|
|
))
|
|
|
|
# Section order
|
|
if len(parsed.section_order) > 1:
|
|
expected = [s for s in CANONICAL_SECTIONS if s in parsed.section_order]
|
|
if parsed.section_order != expected:
|
|
findings.append(Finding(
|
|
"control.section.order", "warning", rel,
|
|
f"Recognized sections out of canonical order: {parsed.section_order} (expected {expected})",
|
|
))
|
|
|
|
# Load link broken (only for files that actually have Load sections)
|
|
for item in parsed.load_items:
|
|
if item.kind != "prose" and item.target:
|
|
if not item.target.startswith(("http://", "https://")):
|
|
t_path = root / item.target
|
|
if not t_path.exists():
|
|
findings.append(Finding(
|
|
"control.load-link.broken", "error", rel,
|
|
f"Load link target not found: '{item.target}'",
|
|
item.target,
|
|
))
|
|
|
|
# Check links in control files
|
|
link_findings = check_markdown_links(cf, root)
|
|
# Filter out same findings already caught by load-link.broken
|
|
for lf in link_findings:
|
|
findings.append(lf)
|
|
|
|
# --- Check wiki files ---
|
|
if wiki_root.exists():
|
|
for wiki_file in sorted(wiki_root.rglob("*.md")):
|
|
if wiki_file.name.startswith("."):
|
|
continue # skip inventory files (.inventory.md)
|
|
wrel = posix_rel(wiki_file, root)
|
|
try:
|
|
text = wiki_file.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
continue
|
|
|
|
fm, body = parse_frontmatter(text)
|
|
|
|
if not fm.present:
|
|
findings.append(Finding(
|
|
"wiki.frontmatter.missing", "warning",
|
|
wrel,
|
|
"Wiki file has no front-matter block (add ---\\nstatus: generated\\nsource-paths: []\\n---)",
|
|
))
|
|
else:
|
|
if fm.status not in ("generated", "authored", "stub"):
|
|
findings.append(Finding(
|
|
"wiki.frontmatter.status.invalid", "error",
|
|
wrel,
|
|
f"Invalid status value '{fm.status}' (must be generated/authored/stub)",
|
|
))
|
|
for sp in fm.source_paths:
|
|
sp_path = root / sp
|
|
if not sp_path.exists():
|
|
findings.append(Finding(
|
|
"wiki.source-path.missing", "warning",
|
|
wrel,
|
|
f"source-paths entry '{sp}' does not exist",
|
|
))
|
|
|
|
# Check links in wiki files
|
|
link_findings = check_markdown_links(wiki_file, root)
|
|
for lf in link_findings:
|
|
findings.append(lf)
|
|
|
|
# --- nav.stale: check nav tables ---
|
|
dirs = top_level_dirs(root)
|
|
dir_names = {d.name for d in dirs}
|
|
dir_rels = {posix_rel(d, root) for d in dirs}
|
|
|
|
# Check wiki/NAV.md for stale entries
|
|
for nav_path_rel in [WIKI_NAV_REL, WIKI_CODEBASE_NAV_REL]:
|
|
nav_path = root / nav_path_rel
|
|
if not nav_path.exists():
|
|
continue
|
|
fm_nav, nav_body = parse_frontmatter(
|
|
nav_path.read_text(encoding="utf-8", errors="replace")
|
|
)
|
|
# Extract dir entries from nav table
|
|
for line in nav_body.splitlines():
|
|
m = re.match(r"\|\s*`([^`/]+)/`", line)
|
|
if m:
|
|
listed_name = m.group(1)
|
|
listed_path = root / listed_name
|
|
if not listed_path.exists():
|
|
findings.append(Finding(
|
|
"nav.stale", "warning",
|
|
nav_path_rel,
|
|
f"Nav table lists '{listed_name}/' but directory does not exist",
|
|
))
|
|
|
|
# Check for dirs present but not in nav (only top-level that have control
|
|
# files). Applies only to status:generated NAVs — authored NAVs are
|
|
# selective by design (curated pointers, not complete inventories).
|
|
for nav_path_rel in [WIKI_NAV_REL, WIKI_CODEBASE_NAV_REL]:
|
|
nav_path = root / nav_path_rel
|
|
if not nav_path.exists() or not file_is_generated(nav_path):
|
|
continue
|
|
fm_nav, nav_body = parse_frontmatter(
|
|
nav_path.read_text(encoding="utf-8", errors="replace")
|
|
)
|
|
listed_names: set[str] = set()
|
|
for line in nav_body.splitlines():
|
|
m = re.match(r"\|\s*`([^`/]+)/`", line)
|
|
if m:
|
|
listed_names.add(m.group(1))
|
|
for d in dirs:
|
|
if d.name not in listed_names and find_control_file(d):
|
|
findings.append(Finding(
|
|
"nav.stale", "warning",
|
|
nav_path_rel,
|
|
f"Nav table omits '{d.name}/' which has a control file",
|
|
))
|
|
|
|
# Sort: errors first, then by path
|
|
findings.sort(key=lambda f: (0 if f.severity == "error" else 1, f.path))
|
|
|
|
error_count = sum(1 for f in findings if f.severity == "error")
|
|
warning_count = sum(1 for f in findings if f.severity == "warning")
|
|
|
|
if as_json:
|
|
output = {
|
|
"schema_version": 2,
|
|
"findings": [
|
|
{
|
|
"id": f.id,
|
|
"severity": f.severity,
|
|
"path": f.path,
|
|
"message": f.message,
|
|
"remediation": _remediation(f.id),
|
|
}
|
|
for f in findings
|
|
],
|
|
"summary": {
|
|
"errors": error_count,
|
|
"warnings": warning_count,
|
|
},
|
|
}
|
|
print(json.dumps(output, indent=2))
|
|
else:
|
|
if error_count > 0:
|
|
print("Errors:")
|
|
for f in findings:
|
|
if f.severity == "error":
|
|
print(f" {f.path} [{f.id}]")
|
|
print(f" {f.message}")
|
|
rem = _remediation(f.id)
|
|
if rem:
|
|
print(f" -> {rem}")
|
|
print()
|
|
|
|
if warning_count > 0:
|
|
print("Warnings:")
|
|
for f in findings:
|
|
if f.severity == "warning":
|
|
print(f" {f.path} [{f.id}]")
|
|
print(f" {f.message}")
|
|
print()
|
|
|
|
if error_count == 0 and warning_count == 0:
|
|
print("No findings. Knowledge layer is healthy.")
|
|
print()
|
|
|
|
print(f"Summary: {error_count} errors, {warning_count} warnings")
|
|
|
|
return EXIT_CHECK_ERRORS if error_count > 0 else EXIT_OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# impact subcommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ImpactHit(NamedTuple):
|
|
wiki_doc: str # repo-relative POSIX path to the wiki doc
|
|
changed_file: str # the changed path that triggered the match
|
|
hit_type: str # direct, directory, control, potential
|
|
|
|
|
|
def cmd_impact(root: Path, changed_paths: list[str]) -> int:
|
|
"""Analyze which wiki documents are affected by a set of changed source paths."""
|
|
wiki_root = root / WIKI_SUBDIR
|
|
|
|
# Normalize input paths: strip whitespace, make repo-relative POSIX, skip empty
|
|
normalized: list[str] = []
|
|
for p in changed_paths:
|
|
p = p.strip()
|
|
if not p:
|
|
continue
|
|
# Normalize to forward slashes, strip leading ./ or /
|
|
p = p.replace("\\", "/")
|
|
if p.startswith("./"):
|
|
p = p[2:]
|
|
if p.startswith("/"):
|
|
p = p[1:]
|
|
normalized.append(p)
|
|
|
|
if not normalized:
|
|
print("No changed paths provided.")
|
|
print()
|
|
print("Usage:")
|
|
print(" knowledge.py impact <path1> [path2 ...]")
|
|
print(" git diff --name-only | knowledge.py impact")
|
|
return EXIT_OK
|
|
|
|
# Collect all wiki docs with their source-paths
|
|
wiki_docs: list[tuple[str, list[str]]] = [] # (wiki_rel_path, source_paths)
|
|
if wiki_root.exists():
|
|
for md_file in sorted(wiki_root.rglob("*.md")):
|
|
if md_file.name.startswith("."):
|
|
continue
|
|
fm, _ = read_wiki_file(md_file)
|
|
if fm.source_paths:
|
|
wiki_docs.append((posix_rel(md_file, root), fm.source_paths))
|
|
|
|
hits: list[ImpactHit] = []
|
|
seen: set[tuple[str, str, str]] = set() # (wiki_doc, changed_file, hit_type)
|
|
|
|
def add_hit(wiki_doc: str, changed_file: str, hit_type: str):
|
|
key = (wiki_doc, changed_file, hit_type)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
hits.append(ImpactHit(wiki_doc, changed_file, hit_type))
|
|
|
|
for changed in normalized:
|
|
changed_normalized = changed.rstrip("/")
|
|
|
|
# 1. Direct hits: wiki doc source-paths lists the exact changed file
|
|
for wiki_path, source_paths in wiki_docs:
|
|
for sp in source_paths:
|
|
sp_normalized = sp.rstrip("/")
|
|
if sp_normalized == changed_normalized:
|
|
add_hit(wiki_path, changed, "direct")
|
|
|
|
# 2. Directory hits: wiki doc source-paths lists a parent directory
|
|
for wiki_path, source_paths in wiki_docs:
|
|
for sp in source_paths:
|
|
sp_normalized = sp.rstrip("/")
|
|
# sp is a parent directory of the changed file
|
|
if changed_normalized.startswith(sp_normalized + "/"):
|
|
add_hit(wiki_path, changed, "directory")
|
|
|
|
# 3. Control file hits: CLAUDE.md in directories containing changed files
|
|
# Walk ancestor directories of the changed file
|
|
changed_path = PurePosixPath(changed_normalized)
|
|
parents = list(changed_path.parents)
|
|
# Include the file's directory (parent of the file)
|
|
for parent in parents:
|
|
parent_str = str(parent)
|
|
if parent_str == ".":
|
|
parent_dir = root
|
|
else:
|
|
parent_dir = root / parent_str
|
|
cf = find_control_file(parent_dir)
|
|
if cf:
|
|
cf_rel = posix_rel(cf, root)
|
|
add_hit(cf_rel, changed, "control")
|
|
|
|
# 4. Potential stale: wiki docs whose source-paths reference files in
|
|
# the same module (same top-level directory subtree) as changed files
|
|
# Determine the module prefix of the changed file (first two path segments, or first one)
|
|
parts = changed_normalized.split("/")
|
|
if len(parts) >= 2:
|
|
module_prefix = parts[0] + "/" + parts[1]
|
|
elif len(parts) == 1:
|
|
module_prefix = parts[0]
|
|
else:
|
|
continue
|
|
|
|
for wiki_path, source_paths in wiki_docs:
|
|
for sp in source_paths:
|
|
sp_normalized = sp.rstrip("/")
|
|
# Same module but not already a direct or directory hit
|
|
if (sp_normalized.startswith(module_prefix + "/") or sp_normalized == module_prefix):
|
|
# Only add if not already matched as direct or directory
|
|
if (wiki_path, changed, "direct") not in seen and \
|
|
(wiki_path, changed, "directory") not in seen:
|
|
add_hit(wiki_path, changed, "potential")
|
|
break # one match per wiki doc per changed file is enough
|
|
|
|
# Output report
|
|
if not hits:
|
|
print("No impact detected.")
|
|
print()
|
|
print(f"Checked {len(normalized)} changed path(s) against {len(wiki_docs)} wiki document(s).")
|
|
return EXIT_OK
|
|
|
|
# Group by hit type
|
|
direct_hits = [h for h in hits if h.hit_type == "direct"]
|
|
directory_hits = [h for h in hits if h.hit_type == "directory"]
|
|
control_hits = [h for h in hits if h.hit_type == "control"]
|
|
potential_hits = [h for h in hits if h.hit_type == "potential"]
|
|
|
|
print("=" * 60)
|
|
print("tome impact report")
|
|
print("=" * 60)
|
|
print()
|
|
print(f"Changed paths: {len(normalized)}")
|
|
print(f"Wiki documents checked: {len(wiki_docs)}")
|
|
print()
|
|
|
|
if direct_hits:
|
|
print("Direct hits (wiki doc source-paths lists the exact changed file):")
|
|
for h in direct_hits:
|
|
print(f" {h.wiki_doc}")
|
|
print(f" <- {h.changed_file}")
|
|
print()
|
|
|
|
if directory_hits:
|
|
print("Directory hits (wiki doc source-paths lists a parent directory):")
|
|
for h in directory_hits:
|
|
print(f" {h.wiki_doc}")
|
|
print(f" <- {h.changed_file}")
|
|
print()
|
|
|
|
if control_hits:
|
|
print("Control file hits (CLAUDE.md in ancestor directories of changed files):")
|
|
for h in control_hits:
|
|
print(f" {h.wiki_doc}")
|
|
print(f" <- {h.changed_file}")
|
|
print()
|
|
|
|
if potential_hits:
|
|
print("Potential stale (wiki docs referencing same module as changed files):")
|
|
for h in potential_hits:
|
|
print(f" {h.wiki_doc}")
|
|
print(f" <- {h.changed_file}")
|
|
print()
|
|
|
|
# Summary
|
|
total = len(direct_hits) + len(directory_hits) + len(control_hits) + len(potential_hits)
|
|
print(f"Total: {total} impact(s) — "
|
|
f"{len(direct_hits)} direct, {len(directory_hits)} directory, "
|
|
f"{len(control_hits)} control, {len(potential_hits)} potential")
|
|
|
|
return EXIT_OK
|
|
|
|
|
|
def _remediation(finding_id: str) -> str:
|
|
return {
|
|
"control.h1.missing": "Add a '# Title' heading as the first H1 in the control file",
|
|
"control.h1.multiple": "Keep exactly one H1 heading in the control file",
|
|
"control.section.order": "Reorder recognized sections: Load -> Warnings -> Handoff",
|
|
"control.load-link.broken": "Fix the link target path in the ## Load section",
|
|
"control.coexist": "Remove one of CLAUDE.md or AGENTS.md from this directory",
|
|
"wiki.frontmatter.missing": "Add YAML front-matter: ---\\nstatus: generated\\nsource-paths: []\\n---",
|
|
"wiki.frontmatter.status.invalid": "Set status to one of: generated, authored, stub",
|
|
"wiki.source-path.missing": "Update source-paths to list existing repo paths",
|
|
"link.broken": "Fix or remove the broken markdown link",
|
|
"nav.resource.missing": "Run 'knowledge.py bootstrap' to create missing resources",
|
|
"nav.wiki.missing": "Run 'knowledge.py bootstrap' to create missing wiki files",
|
|
"nav.stale": "Run 'knowledge.py refresh' to regenerate nav tables",
|
|
}.get(finding_id, "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
# Emit UTF-8 regardless of the host console code page (Windows consoles
|
|
# default to cp1252/cp437 and would mangle em-dashes in reports).
|
|
for stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
stream.reconfigure(encoding="utf-8")
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="knowledge.py",
|
|
description="tome — deterministic knowledge-layer tool",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
|
|
# bootstrap
|
|
subparsers.add_parser("bootstrap", help="Scaffold wiki/ and navigate skill supporting files")
|
|
|
|
# refresh
|
|
subparsers.add_parser("refresh", help="Refresh status:generated files; report stale knowledge")
|
|
|
|
# context
|
|
ctx_parser = subparsers.add_parser("context", help="Resolve navigation context for a path")
|
|
ctx_parser.add_argument("path", help="Target path (repo-relative or absolute)")
|
|
ctx_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
|
|
# check
|
|
chk_parser = subparsers.add_parser("check", help="Validate knowledge-layer health")
|
|
chk_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
|
|
# impact
|
|
imp_parser = subparsers.add_parser("impact", help="Analyze wiki impact of changed source paths")
|
|
imp_parser.add_argument("paths", nargs="*", help="Changed file paths (or pipe via stdin)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
root = find_repo_root()
|
|
|
|
if args.subcommand == "bootstrap":
|
|
sys.exit(cmd_bootstrap(root))
|
|
elif args.subcommand == "refresh":
|
|
sys.exit(cmd_refresh(root))
|
|
elif args.subcommand == "context":
|
|
sys.exit(cmd_context(root, args.path, getattr(args, "json", False)))
|
|
elif args.subcommand == "check":
|
|
sys.exit(cmd_check(root, getattr(args, "json", False)))
|
|
elif args.subcommand == "impact":
|
|
# Accept paths from positional args or stdin
|
|
paths = args.paths if args.paths else []
|
|
if not paths and not sys.stdin.isatty():
|
|
paths = [line for line in sys.stdin.read().splitlines() if line.strip()]
|
|
sys.exit(cmd_impact(root, paths))
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(EXIT_USAGE)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|