ckb/.agents/skills/ckb-export-okf/scripts/export_okf.py
Michał Kopeć 65b1e422b3 Add decision log, scriptify OKF export and lint detection
Decision log (VERSION 1.6.0, kb_schema_version 1.4):
- wiki/decisions/ scaffold — numbered NNNN-slug.md records, own index
  (with status vocabulary) and log
- type: decision adds status/decided_on/decided_by/affects/review_on;
  supersedes/superseded_by carry history and must be set on both sides
- New ckb-decide skill: records decisions and answers what/why/who/when,
  what superseded what, and what is still open. Decision pages are
  append-only — a changed mind is a new superseding decision
- Graph gains decided_by and affects edge types
- ckb-ingest routes decisions found in raw material to this format;
  ckb-retrieve gains the decisions index as a cascade step

Scriptified the mechanical skills:
- ckb-export-okf/scripts/export_okf.py does the whole OKF transform
  (frontmatter remap, link rewriting, index/log regeneration, conformance
  validation); --check validates without writing
- ckb-lint/scripts/lint_report.py does the read-only detection half
  (conformance, freshness, confidence, retention, decisions, orphans,
  graph, index/log, source.yaml); judgment calls stay with the model

Also: removed the duplicate personal quiz skill, fixed stale cbk-quiz
doc paths, gitignored __pycache__.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:50:36 +02:00

529 lines
18 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 Michał Kopeć
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Export wiki/ as an Open Knowledge Format (OKF) v0.1 bundle at outputs/okf/.
Usage:
python3 export_okf.py [--check] [--out DIR]
Run from the repository root (the directory containing wiki/ and outputs/).
--check validate only: build the bundle in a temporary directory, run the
conformance checks, print the report, and write nothing to outputs/.
--out override the output directory (default: outputs/okf).
The transform is fully deterministic: two runs over an unchanged wiki/ produce
byte-identical output. Every directory walk and generated list is sorted.
"""
import argparse
import re
import shutil
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path.cwd()
WIKI = REPO_ROOT / "wiki"
OKF_VERSION = "0.1"
RESERVED = {"index.md", "log.md"}
# wiki/ frontmatter keys that ride along unchanged as OKF extension fields.
PASSTHROUGH_EXT = [
"confidence",
"quality",
"retention",
"supersedes",
"superseded_by",
"freshness_window_days",
]
# Keys with no valid home in an OKF bundle.
DROPPED = {"kb_schema_version"}
VERB_MAP = {
"CREATE": "Creation",
"UPDATE": "Update",
"DELETE": "Deprecation",
"RESTRUCTURE": "Update",
}
# --------------------------------------------------------------------------
# frontmatter
# --------------------------------------------------------------------------
def parse_frontmatter(text):
"""Return (dict, body). Flat `key: value` YAML only — that is all the
schema uses. Unparseable or absent frontmatter yields ({}, text)."""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
raw = text[4:end]
body = text[end + 5 :]
fm = {}
for line in raw.split("\n"):
line = line.rstrip()
if not line or line.lstrip().startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
fm[key.strip()] = unquote(value.strip())
return fm, body
def unquote(value):
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
def yaml_scalar(value):
"""Emit a value that round-trips through the parser above."""
s = str(value)
if s == "":
return '""'
if s[0] in "\"'&*!|>%@`[]{},#" or s[-1] == ":" or ": " in s or s.strip() != s:
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
return s
def render_frontmatter(pairs):
lines = ["---"]
for key, value in pairs:
lines.append(f"{key}: {yaml_scalar(value)}")
lines.append("---")
return "\n".join(lines) + "\n"
# --------------------------------------------------------------------------
# body text
# --------------------------------------------------------------------------
def first_h1(body):
for line in body.split("\n"):
if line.startswith("# "):
return line[2:].strip()
return None
def slug_title(filename):
stem = Path(filename).stem
return " ".join(w.capitalize() for w in re.split(r"[-_]+", stem) if w)
def strip_wikilinks(body, counters):
"""Drop the [[...]] half of every dual-link, keeping the markdown half."""
def drop_pair(m):
counters["wikilinks"] += 1
return m.group("keep")
# [[X]] / [text](path) and [text](path) / [[X]]
body = re.sub(
r"\[\[[^\]]*\]\]\s*/\s*(?P<keep>\[[^\]]*\]\([^)]*\))", drop_pair, body
)
body = re.sub(
r"(?P<keep>\[[^\]]*\]\([^)]*\))\s*/\s*\[\[[^\]]*\]\]", drop_pair, body
)
# Any remaining bare wikilink degrades to its plain label.
def bare(m):
counters["wikilinks"] += 1
label = m.group(1)
return label.split("|", 1)[-1].strip()
return re.sub(r"\[\[([^\]]*)\]\]", bare, body)
def rewrite_links(body, counters):
"""Strip the /wiki prefix from repo-root-absolute intra-wiki links and
count (but never touch) cross-cascade linked//libs/ references."""
def repl(m):
target = m.group(2)
if re.match(r"^\.{0,2}/?(linked|libs)/", target):
counters["cascade_refs"] += 1
return m.group(0)
if target.startswith("/wiki/"):
counters["rewritten"] += 1
target = target[5:]
elif target == "/wiki" or target == "/wiki/":
counters["rewritten"] += 1
target = "/"
return f"[{m.group(1)}]({target})"
return re.sub(r"\[([^\]]*)\]\(([^)]*)\)", repl, body)
def transform_body(body, counters):
return rewrite_links(strip_wikilinks(body, counters), counters)
# --------------------------------------------------------------------------
# source tree
# --------------------------------------------------------------------------
class Page:
def __init__(self, relpath, text):
self.relpath = relpath # PosixPath relative to wiki/
self.fm, self.body = parse_frontmatter(text)
self.title = first_h1(self.body) or slug_title(relpath.name)
self.description = self.fm.get("tldr", "")
def read_tree(wiki):
pages, logs, indexes, assets = {}, {}, {}, []
for path in sorted(wiki.rglob("*")):
if not path.is_file() or path.name.startswith("."):
continue
rel = path.relative_to(wiki)
if path.suffix != ".md":
assets.append(rel)
continue
text = path.read_text(encoding="utf-8")
if path.name == "index.md":
indexes[rel] = text
elif path.name == "log.md":
logs[rel] = text
else:
pages[rel] = Page(rel, text)
return pages, logs, indexes, assets
# --------------------------------------------------------------------------
# emitters
# --------------------------------------------------------------------------
def emit_concept(page, counters, issues):
ptype = page.fm.get("type", "").strip()
if not ptype:
ptype = "unknown"
issues.append(f"{page.relpath}: no `type` in source frontmatter (exported as `unknown`)")
pairs = [("type", ptype), ("title", page.title)]
if page.description:
pairs.append(("description", page.description))
if page.fm.get("resource"):
pairs.append(("resource", page.fm["resource"]))
if page.fm.get("last_updated"):
pairs.append(("timestamp", page.fm["last_updated"]))
for key in PASSTHROUGH_EXT:
if key in page.fm:
pairs.append((key, page.fm[key]))
for key in sorted(page.fm):
if key in DROPPED or key in PASSTHROUGH_EXT:
continue
if key in ("type", "resource", "tldr", "last_updated"):
continue
pairs.append((key, page.fm[key]))
body = transform_body(page.body, counters).lstrip("\n")
return render_frontmatter(pairs) + "\n" + body.rstrip("\n") + "\n"
def index_entries(directory, pages, logs, indexes, assets):
"""Every direct child of `directory` (a PosixPath relative to wiki/, or
Path('.') for the root), as (title, href, description) — sorted, so the
generated list is stable across runs."""
entries = []
for rel, page in pages.items():
if rel.parent == directory:
entries.append((page.title, rel.name, page.description))
for rel in logs:
if rel.parent == directory:
entries.append(("Change Log", rel.name, "Chronological record of changes in this directory."))
for rel in assets:
if rel.parent == directory:
entries.append((rel.name, rel.name, ""))
subdirs = set()
for rel in list(pages) + list(logs) + list(indexes) + list(assets):
parent = rel.parent
while parent != Path("."):
if parent.parent == directory:
subdirs.add(parent)
parent = parent.parent
for sub in subdirs:
sub_index = sub / "index.md"
title = slug_title(sub.name)
if sub_index in indexes:
title = first_h1(parse_frontmatter(indexes[sub_index])[1]) or title
entries.append((title, f"{sub.name}/index.md", ""))
return sorted(entries, key=lambda e: e[1])
def emit_index(directory, pages, logs, indexes, assets, is_root):
lines = []
if is_root:
lines.append(render_frontmatter([("okf_version", OKF_VERSION)]).rstrip("\n"))
lines.append("")
src = indexes.get(directory / "index.md" if directory != Path(".") else Path("index.md"), "")
heading = first_h1(parse_frontmatter(src)[1]) if src else None
lines.append(f"# {heading or slug_title(directory.name) or 'Index'}")
lines.append("")
for title, href, description in index_entries(directory, pages, logs, indexes, assets):
if description:
lines.append(f"* [{title}]({href}) - {description}")
else:
lines.append(f"* [{title}]({href})")
return "\n".join(lines).rstrip("\n") + "\n"
LOG_HEADER_RE = re.compile(
r"^##\s*\[?(?P<date>\d{4}-\d{2}-\d{2})(?:[ T](?P<time>\d{2}:\d{2}))?\]?\s*-\s*\[?(?P<action>[^\]\n]*?)\]?\s*$"
)
FIELD_RE = re.compile(r"^-\s*\*\*(?P<key>[^:*]+):?\*\*:?\s*(?P<value>.*)$")
def parse_log(text):
"""Return (heading, [entry dicts]) from a Rule B formatted log."""
body = parse_frontmatter(text)[1]
heading = first_h1(body)
entries, current = [], None
for line in body.split("\n"):
m = LOG_HEADER_RE.match(line.strip())
if m:
if current:
entries.append(current)
current = {
"date": m.group("date"),
"time": m.group("time") or "",
"action": (m.group("action") or "").strip(),
"fields": {},
}
continue
if current is None:
continue
f = FIELD_RE.match(line.strip())
if f:
current["fields"][f.group("key").strip().lower()] = f.group("value").strip()
if current:
entries.append(current)
return heading, entries
def verb_for(action):
for token in re.split(r"[/,\s]+", action.upper()):
if token in VERB_MAP:
return VERB_MAP[token]
return "Update"
def files_to_links(value, counters):
"""`wiki/a.md`, `wiki/b.md` -> [/a.md](/a.md), [/b.md](/b.md)
Only entries that name an actual file inside wiki/ become links. A
Rule B entry may also name a directory or carry a parenthetical
("`wiki/decisions/` (directory)") — those stay plain text, since a link
to them would not resolve inside the bundle."""
out = []
for chunk in re.split(r",\s*", value):
chunk = chunk.strip()
if not chunk:
continue
m = re.match(r"^`([^`]+)`(.*)$", chunk)
name = (m.group(1) if m else chunk).strip()
trailer = (m.group(2).strip() if m else "")
if name.startswith("wiki/") and name.endswith(".md") and not re.search(r"\s", name):
href = "/" + name[len("wiki/") :]
rendered = f"[{href}]({href})"
else:
rendered = name
out.append(f"{rendered} {trailer}".strip() if trailer else rendered)
return ", ".join(out)
def emit_log(text, counters):
heading, entries = parse_log(text)
lines = [f"# {heading or 'Change Log'}", ""]
by_date = {}
for entry in entries:
by_date.setdefault(entry["date"], []).append(entry)
for date in sorted(by_date, reverse=True):
lines.append(f"## {date}")
lines.append("")
for entry in by_date[date]:
verb = verb_for(entry["action"])
fields = entry["fields"]
desc = transform_body(fields.get("description", ""), counters).strip()
files = files_to_links(fields.get("file affected", ""), counters)
source = fields.get("source", "").strip().rstrip(".")
parts = []
if files:
parts.append(files)
if desc:
parts.append(desc)
text_part = "".join(parts) if parts else "(no description recorded)"
bullet = f"* **{verb}**: {text_part}"
if source:
bullet += f" (source: {source})"
lines.append(bullet.rstrip(".") + ".")
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"
# --------------------------------------------------------------------------
# build + validate
# --------------------------------------------------------------------------
def build(out_dir):
pages, logs, indexes, assets = read_tree(WIKI)
counters = {"wikilinks": 0, "cascade_refs": 0, "rewritten": 0}
issues = []
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
for rel, page in sorted(pages.items()):
dest = out_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(emit_concept(page, counters, issues), encoding="utf-8")
directories = {Path(".")}
for rel in list(pages) + list(logs) + list(indexes) + list(assets):
parent = rel.parent
while parent != Path("."):
directories.add(parent)
parent = parent.parent
for directory in sorted(directories):
dest = out_dir / directory / "index.md"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
emit_index(directory, pages, logs, indexes, assets, directory == Path(".")),
encoding="utf-8",
)
for rel, text in sorted(logs.items()):
dest = out_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(emit_log(text, counters), encoding="utf-8")
for rel in assets:
dest = out_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(WIKI / rel, dest)
report = {
"concepts": len(pages),
"indexes": len(directories),
"logs": len(logs),
"assets": len(assets),
"counters": counters,
"issues": issues,
}
return report
def validate(out_dir):
"""Conformance checks against the generated bundle. Returns a list of
strings; empty means the bundle conforms."""
problems = []
md_files = sorted(p for p in out_dir.rglob("*.md"))
for path in md_files:
rel = path.relative_to(out_dir)
text = path.read_text(encoding="utf-8")
fm, body = parse_frontmatter(text)
if path.name == "index.md":
if rel == Path("index.md"):
if set(fm) - {"okf_version"}:
problems.append(f"{rel}: root index frontmatter must contain only okf_version")
elif fm:
problems.append(f"{rel}: nested index.md must have no frontmatter")
elif path.name == "log.md":
for line in body.split("\n"):
if line.startswith("## ") and not re.match(r"^## \d{4}-\d{2}-\d{2}$", line.strip()):
problems.append(f"{rel}: log header is not `## YYYY-MM-DD`: {line.strip()}")
else:
if not fm.get("type", "").strip():
problems.append(f"{rel}: missing or empty `type`")
for m in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", body):
target = m.group(1).split("#", 1)[0].strip()
if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target):
continue
if re.match(r"^\.{0,2}/?(linked|libs)/", target):
continue # intentionally left unconverted
if target.startswith("/"):
candidate = out_dir / target.lstrip("/")
else:
candidate = (path.parent / target).resolve()
if not candidate.exists():
problems.append(f"{rel}: intra-bundle link does not resolve: {target}")
return problems
def main():
parser = argparse.ArgumentParser(description="Export wiki/ as an OKF v0.1 bundle.")
parser.add_argument("--check", action="store_true", help="validate only; write nothing")
parser.add_argument("--out", default="outputs/okf", help="output directory (default: outputs/okf)")
args = parser.parse_args()
if not WIKI.is_dir():
print(f"error: no wiki/ directory under {REPO_ROOT} — run from the repository root", file=sys.stderr)
return 2
if args.check:
tmp = Path(tempfile.mkdtemp(prefix="okf-check-"))
try:
out_dir = tmp / "okf"
report = build(out_dir)
problems = validate(out_dir)
print_report(report, problems, out_dir, checked_only=True)
finally:
shutil.rmtree(tmp, ignore_errors=True)
else:
out_dir = REPO_ROOT / args.out
report = build(out_dir)
problems = validate(out_dir)
print_report(report, problems, out_dir, checked_only=False)
return 1 if problems else 0
def print_report(report, problems, out_dir, checked_only):
counters = report["counters"]
print(f"okf_version: {OKF_VERSION}")
print(f"concept documents exported: {report['concepts']}")
print(f"index.md files regenerated: {report['indexes']}")
print(f"log.md files regenerated: {report['logs']}")
if report["assets"]:
print(f"non-markdown files copied: {report['assets']}")
print(f"wikilinks stripped: {counters['wikilinks']}")
print(f"/wiki/ links rewritten to bundle-root: {counters['rewritten']}")
print(f"linked//libs/ cross-cascade refs left unconverted: {counters['cascade_refs']}")
for issue in report["issues"]:
print(f"SOURCE ISSUE: {issue}")
if problems:
print(f"NONCONFORMANT: {len(problems)} problem(s) in the generated bundle:")
for p in problems:
print(f" - {p}")
else:
print("conformance: OK")
if checked_only:
print("output: none (--check)")
else:
print(f"output: {out_dir}")
if __name__ == "__main__":
sys.exit(main())