184 lines
5.8 KiB
Python
184 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""List all lore entries in `.lore/` as JSON or human-readable text.
|
|
|
|
Usage:
|
|
python list_entries.py # human-readable
|
|
python list_entries.py --json # JSON output
|
|
python list_entries.py --scope=frontend
|
|
python list_entries.py --layer=ARCH
|
|
|
|
Walks `.lore/_global/*` and `.lore/scopes/*/*` and parses every
|
|
Markdown bullet that matches the entry format. Output is one record per
|
|
entry with these fields:
|
|
|
|
id full ID, e.g. "ARCH-2026-07-09-a3f2"
|
|
layer prefix, e.g. "ARCH" / "DEC" / "CONV"
|
|
layer_file source file stem, e.g. "ARCHITECTURE"
|
|
scope scope name, or "_global"
|
|
file path relative to .lore/, e.g. "scopes/frontend/ARCHITECTURE.md"
|
|
text entry body, with tags stripped
|
|
tags dict of tag name -> value, e.g. {"added": "2026-07-09", "verified": "2026-07-15"}
|
|
last_verified value of #verified tag, or None
|
|
|
|
Used by:
|
|
- query / audit / compress workflows (pre-step enumeration)
|
|
- find_duplicates.py
|
|
- find_stale.py
|
|
"""
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
# Schema version this skill understands. Bumped only on breaking
|
|
# config changes; see references/compatibility.md.
|
|
KNOWN_SCHEMA_VERSION = 1
|
|
|
|
|
|
def check_schema_version(lore_root: Path) -> None:
|
|
"""Warn if .lore/.config.json is missing or has an unknown schema_version.
|
|
|
|
Output goes to stderr so it does not pollute --json consumers.
|
|
Idempotent and best-effort: any failure (missing file, malformed
|
|
JSON, permission error) is silent — config is optional and the
|
|
user can address it separately.
|
|
"""
|
|
cfg_path = lore_root / ".config.json"
|
|
if not cfg_path.exists():
|
|
return
|
|
try:
|
|
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return
|
|
|
|
version = cfg.get("schema_version")
|
|
if version is None:
|
|
print(
|
|
"[WARN] .lore/.config.json has no schema_version field. "
|
|
"Add \"schema_version\": 1 so future lore upgrades can detect "
|
|
"this config and prompt for migrations when they exist.",
|
|
file=sys.stderr,
|
|
)
|
|
elif isinstance(version, int) and version > KNOWN_SCHEMA_VERSION:
|
|
print(
|
|
f"[WARN] .lore/.config.json#schema_version={version} is newer "
|
|
f"than this lore skill expects (max: {KNOWN_SCHEMA_VERSION}). "
|
|
"Pull the latest lore from upstream.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def find_lore_root(start: Path) -> Path:
|
|
"""Walk up from start to find the project root containing .lore/."""
|
|
p = start.resolve()
|
|
while p != p.parent:
|
|
if (p / ".lore").is_dir():
|
|
return p / ".lore"
|
|
p = p.parent
|
|
return None
|
|
|
|
|
|
def parse_entry(line: str):
|
|
"""Parse one Markdown bullet line. Returns dict or None if not an entry."""
|
|
m = re.match(
|
|
r"^\s*-\s*\[([A-Z]+)-(\d{4}-\d{2}-\d{2})-([a-f0-9]{4})\]\s+(.*?)\s*$",
|
|
line,
|
|
)
|
|
if not m:
|
|
return None
|
|
|
|
layer, date, h, rest = m.group(1), m.group(2), m.group(3), m.group(4)
|
|
eid = f"{layer}-{date}-{h}"
|
|
|
|
# Extract #tag:value pairs
|
|
tag_re = re.compile(r"#(added|verified|stale|archived):(\S+)")
|
|
tags = {name: val for name, val in tag_re.findall(rest)}
|
|
text = tag_re.sub("", rest).strip()
|
|
|
|
return {
|
|
"id": eid,
|
|
"layer": layer,
|
|
"layer_file": None, # filled in by caller
|
|
"scope": None, # filled in by caller
|
|
"file": None, # filled in by caller
|
|
"text": text,
|
|
"tags": tags,
|
|
"last_verified": tags.get("verified"),
|
|
}
|
|
|
|
|
|
def collect_entries(root: Path):
|
|
entries = []
|
|
layers_dirs = [("_global", root / "_global"), ("scopes", root / "scopes")]
|
|
|
|
for section_name, section_path in layers_dirs:
|
|
if not section_path.exists():
|
|
continue
|
|
for md_file in sorted(section_path.rglob("*.md")):
|
|
if section_name == "_global":
|
|
scope = "_global"
|
|
else:
|
|
scope = md_file.parent.name
|
|
layer_file = md_file.stem
|
|
try:
|
|
with open(md_file, encoding="utf-8") as f:
|
|
for line in f:
|
|
e = parse_entry(line)
|
|
if e is None:
|
|
continue
|
|
e["scope"] = scope
|
|
e["layer_file"] = layer_file
|
|
e["file"] = str(md_file.relative_to(root))
|
|
entries.append(e)
|
|
except OSError as exc:
|
|
print(f"warning: cannot read {md_file}: {exc}", file=sys.stderr)
|
|
return entries
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
|
|
scope_filter = None
|
|
layer_filter = None
|
|
json_output = "--json" in args
|
|
|
|
for arg in args:
|
|
if arg.startswith("--scope="):
|
|
scope_filter = arg.split("=", 1)[1]
|
|
elif arg.startswith("--layer="):
|
|
layer_filter = arg.split("=", 1)[1]
|
|
|
|
root = find_lore_root(Path("."))
|
|
if root is None:
|
|
print("error: .lore/ not found (run from project root or below)",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
check_schema_version(root)
|
|
entries = collect_entries(root)
|
|
|
|
if scope_filter:
|
|
entries = [e for e in entries if e["scope"] == scope_filter]
|
|
if layer_filter:
|
|
entries = [e for e in entries if e["layer"] == layer_filter]
|
|
|
|
if json_output:
|
|
print(json.dumps(entries, indent=2, ensure_ascii=False))
|
|
return
|
|
|
|
if not entries:
|
|
print("(no entries)")
|
|
return
|
|
|
|
for e in entries:
|
|
verified = (
|
|
f" [verified:{e['last_verified']}]" if e["last_verified"] else ""
|
|
)
|
|
stale = " [STALE]" if "stale" in e["tags"] else ""
|
|
print(f"[{e['file']}] {e['id']} {e['text']}{verified}{stale}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|