Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Schemas for LLM-maintained markdown wikis. Each variant is a self-contained template you copy into a new wiki repo: an always-loaded `CLAUDE.md` core, on-demand `workflows/` procedures, and a stdlib-only lint tool (`lint.py` holds the variant's configuration; the `wikilint/` package holds the shared engine, byte-identical across variants) that handles every mechanical health check so the model only spends judgment where judgment is needed.

Every wiki produced from these templates is a conformant [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle, so it can be consumed by any OKF-aware agent or tool without translation.
Every wiki produced from these templates is a conformant [Open Knowledge Format (OKF) v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle, so it can be consumed by any OKF-aware agent or tool without translation.

## Where this sits in the OKF ecosystem

Expand Down Expand Up @@ -43,7 +43,7 @@ The schemas follow a few rules, informed by how agent-maintained wikis actually
- **Trust is structural, not remembered.** Immutable `raw/`, append-only `log.md`, a git commit per operation, a pre-commit hook that lints the staged snapshot (exactly the bytes that will land) and makes lint errors uncommittable, and commit-pinned verification fields. Agent-maintained judgment metadata decays, so `confidence:` is reduced to the two states a lint can actually check (`low` = uncited, `contested` = sources disagree and the body must explain), inferred claims are marked inline with `(inferred)`, and tags are validated against a `taxonomy.md`, whose `## Page types` section also describes every allowed page type with a one-line meaning, so each bundle self-describes its type vocabulary to OKF consumers.
- **Contested is a state to exit.** The documented failure mode of agent wikis is contradictions accumulating faster than they resolve. Lint flags contested pages older than 30 days; the reconcile workflow rewrites in place, moving losing claims to a dated "Superseded claims" section instead of deleting them.
- **Autonomous but reversible.** The maintenance workflow runs unattended on a `maintenance` branch with an exhaustively-listed set of safe actions (mechanical fixes, index rebuild, unambiguous cross-links); everything else becomes a proposal. The human reviews the branch diff and merges. Nothing automated ever lands on main directly.
- **Native OKF conformance.** Every wiki is an [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle: markdown files with YAML frontmatter, ordinary markdown links as the edge form (bundle-absolute `[title](/dir/page.md)` preferred), reserved `index.md` (stamped with `okf_version` frontmatter by `rebuild-index`) and `log.md` (date-grouped headings, bold-action-word entries). `check_okf` enforces the spec's three conformance rules: parseable frontmatter on every non-reserved `.md`, a non-empty `type`, and reserved-file structure. Scriptorium's schemas are a strict superset of OKF's (its recommended `timestamp` field is our `updated`; its `resource` is our `source_path`; its `title` is optional, with the index prettifying filenames when absent). One deliberate deviation: `raw/` is excluded from conformance because prime directive 1 makes those sources immutable, and OKF has no concept of a non-concept directory.
- **Native OKF conformance.** Every wiki is an [OKF v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle: markdown files with YAML frontmatter, ordinary markdown links as the edge form (bundle-absolute `[title](/dir/page.md)`, the form v0.2 §6.1 recommends), reserved `index.md` (stamped with `okf_version` frontmatter by `rebuild-index`, the mechanism §12 specifies) and `log.md` (date-grouped headings, bold-action-word entries). `check_okf` enforces the spec's three conformance rules: parseable frontmatter on every non-reserved `.md`, a non-empty `type`, and reserved-file structure. Provenance uses the spec's `sources` shape (§5.1): mapping entries with a required `resource`, enforced by `check_sources`, with pre-0.2 string entries downgraded to warnings. Scriptorium's schemas are a strict superset of OKF's (the spec's `generated.at` is our `updated`; its `resource` is our `source_path`; its `title` is optional, with the index prettifying filenames when absent). One deliberate deviation: `raw/` is excluded from conformance because prime directive 1 makes those sources immutable, and OKF has no concept of a non-concept directory.
- **Opt-in extension points for non-wiki trees.** The engine can also lint markdown trees that aren't wikis (a findings folder, a labs journal): `okf_conformance: False` turns off the OKF rules for trees that aren't bundles, `non_page_allowed` accepts glob patterns, `index_file`/`index_body_fn` relocate and reshape the generated index (`index_file: None` disables it), `extra_secret_patterns`/`secret_allow_res` extend the secrets scan, and `extra_checks` runs custom callables. Every knob's default lives once in `wikilint/settings.py` (`DEFAULTS`) and preserves the original behavior; a variant's `lint.py` lists a key only to override it. Bad values (a malformed regex, an out-of-tree `index_file`, a non-callable check) are rejected at startup with a clear message rather than a mid-run traceback.

## Unattended maintenance
Expand Down
2 changes: 1 addition & 1 deletion codebase-large/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ updated: 2026-04-10 # bumped on every edit
description: One line saying what this page covers, used for scanning.
subsystem: auth # or 'global' for cross-cutting pages
tags: []
sources: []
sources: [] # entries are mappings: - resource: /dir/page.md (bundle-absolute, OKF v0.2)
confidence: low | contested # optional; absent means normal
---
```
Expand Down
2 changes: 1 addition & 1 deletion codebase-large/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"inbox_warn_count": 10,
"inbox_warn_age_days": 14,
# Hot-core size guard: warn when CLAUDE.md exceeds this many lines.
"claude_md_max_lines": 145,
"claude_md_max_lines": 200,
# Every page tag must appear in this file (one `- tag — meaning` line each).
"taxonomy_file": "taxonomy.md",
# Contested pages untouched for this many days get flagged for reconcile.
Expand Down
38 changes: 36 additions & 2 deletions codebase-large/wikilint/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ def check_links_and_orphans(pages, report, root):
if target.rel != p.rel:
inbound[target.rel] += 1
for field in CONFIG["path_fields"] + CONFIG["edge_fields"]:
for value in p.field_list(field):
for value in p.path_list(field):
# External resources (URLs) are provenance, not wiki edges.
if URI_SCHEME_RE.match(value):
continue
target = resolve_link(value, by_stem, by_rel)
if target is None:
report.error(
Expand Down Expand Up @@ -606,8 +609,39 @@ def check_log(root, report):
)


def check_sources(pages, report):
"""OKF v0.2 sources shape (SPEC §5.1): each entry is a mapping whose
required resource names either an external URL or a bundle-absolute page
path. Plain-string entries (the pre-0.2 shape) still resolve as paths in
check_links_and_orphans, but warn until installs migrate."""
if not CONFIG["okf_conformance"]:
return
for p in pages:
for i, entry in enumerate(p.field_list("sources"), 1):
if isinstance(entry, dict):
resource = entry.get("resource", "")
if not resource:
report.error(
"sources", p.rel,
f"sources entry {i} has no resource "
"(required per entry, OKF v0.2 §5.1)",
)
elif not URI_SCHEME_RE.match(resource) and not resource.startswith("/"):
report.warning(
"sources", p.rel,
f"sources entry {i}: bundle paths should be "
f"bundle-absolute (/dir/page.md), got {resource!r}",
)
else:
report.warning(
"sources", p.rel,
f"sources entry {i} is a plain string; the OKF v0.2 shape "
"is '- resource: /dir/page.md'",
)


def check_okf(pages, report, root):
"""OKF v0.1 conformance: (1) every non-reserved .md parses as frontmatter,
"""OKF v0.2 conformance: (1) every non-reserved .md parses as frontmatter,
(2) with a non-empty type, (3) reserved files follow their structure.
Pages are skipped here: check_frontmatter is strictly stricter. log.md's
structure is owned by check_log, not re-validated here. raw/ is excluded
Expand Down
1 change: 1 addition & 0 deletions codebase-large/wikilint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def gather_report(root):
checks.check_mermaid(pages, report)
checks.check_adrs(pages, report, root)
checks.check_log(root, report)
checks.check_sources(pages, report)
checks.check_okf(pages, report, root)
check_index_drift(pages, report, root)
for extra in CONFIG["extra_checks"]:
Expand Down
47 changes: 42 additions & 5 deletions codebase-large/wikilint/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
GENERATED_MARKER = "<!-- generated by lint.py: edit above this line only -->"

# OKF (Open Knowledge Format) version this engine produces and validates.
OKF_VERSION = "0.1"
OKF_VERSION = "0.2"

# Pages allowed to break kebab-case and to have no inbound links.
FILENAME_EXEMPT = ("README.md", "OWNERS.md")
Expand Down Expand Up @@ -43,6 +43,10 @@
ADR_FILE_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.md$")
YAML_FENCE_RE = re.compile(r"```yaml\n(.*?)```", re.DOTALL)
KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$")
# A mapping line inside a block list ("resource: /sources/foo.md"). The
# required whitespace after the colon keeps bare URLs (scheme:// has none)
# parsing as plain strings.
LIST_MAP_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):\s+(\S.*)$")


class Report:
Expand Down Expand Up @@ -100,8 +104,11 @@ def as_list(value):
def parse_frontmatter(text):
"""Minimal YAML frontmatter parser: flat keys, inline and block lists.

Returns (fields, error). Nested mappings beyond one list level are not
supported by design; the page schemas only use flat fields.
Block-list items may be one-level mappings ("- resource: /x.md" plus
same-item continuation lines), the OKF v0.2 sources shape. Deeper
nesting is not supported by design; the page schemas stay flat.

Returns (fields, error).
"""
if not text.startswith("---"):
return None, "missing frontmatter"
Expand All @@ -115,6 +122,7 @@ def parse_frontmatter(text):
return None, "unterminated frontmatter"
fields = {}
current_list_key = None
current_item = None # dict being extended by continuation lines, or None
for line in lines[1:end]:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
Expand All @@ -125,6 +133,7 @@ def parse_frontmatter(text):
return None, f"unparseable frontmatter line: {stripped!r}"
key, raw_val = m.group(1), m.group(2).strip()
current_list_key = None
current_item = None
if raw_val == "":
fields[key] = []
current_list_key = key
Expand All @@ -137,8 +146,18 @@ def parse_frontmatter(text):
else:
fields[key] = unquote(raw_val)
elif stripped.startswith("- ") and current_list_key is not None:
fields[current_list_key].append(unquote(stripped[2:]))
# Indented non-list lines (nested mappings) are tolerated but ignored.
item_text = stripped[2:]
m = LIST_MAP_RE.match(item_text)
if m:
current_item = {m.group(1): unquote(m.group(2))}
fields[current_list_key].append(current_item)
else:
current_item = None
fields[current_list_key].append(unquote(item_text))
elif current_item is not None and LIST_MAP_RE.match(stripped):
m = LIST_MAP_RE.match(stripped)
current_item[m.group(1)] = unquote(m.group(2))
# Other indented non-list lines are tolerated but ignored.
return fields, None


Expand Down Expand Up @@ -234,6 +253,20 @@ def type(self):
def field_list(self, name):
return as_list((self.fields or {}).get(name))

def path_list(self, name):
"""Path values of a field: OKF v0.2 mapping entries contribute their
resource, plain strings pass through (the legacy shape). Mappings
without a resource contribute nothing; check_sources reports them."""
values = []
for entry in self.field_list(name):
if isinstance(entry, dict):
resource = entry.get("resource", "")
if resource:
values.append(resource)
else:
values.append(entry)
return values


def discover_pages(root, report):
# Normalize through PurePosixPath so "./x" and "x" compare equal to the
Expand Down Expand Up @@ -304,6 +337,10 @@ def build_link_index(pages):

def resolve_link(target, by_stem, by_rel):
target = target.split("|")[0].split("#")[0].strip()
# Bundle-absolute (leading /) targets resolve against the root, where
# page rel paths have no leading slash.
if target.startswith("/"):
target = target[1:]
if not target:
return None
if target in by_rel:
Expand Down
2 changes: 1 addition & 1 deletion codebase-large/wikilint/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# these bare as CONFIG[key] is safe because configure() merges DEFAULTS under
# every variant config.
DEFAULTS = {
# Enforce OKF v0.1 conformance (check_okf) and stamp okf_version
# Enforce OKF v0.2 conformance (check_okf) and stamp okf_version
# frontmatter into the rebuilt index. Off for non-OKF markdown trees.
"okf_conformance": True,
# Report pages with no inbound links (plus unlinked-mention hints).
Expand Down
2 changes: 1 addition & 1 deletion codebase/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ wiki/

## Page types and frontmatter

Every page starts with YAML frontmatter. All pages require `type`, `created`, `updated`, `description` (one accurate sentence; it is how pages are found without being opened), and `tags` (each tag must appear in `taxonomy.md`, which also describes the allowed page types; introducing a tag means adding it there in the same commit). Optional on any page: `sources` (wiki paths backing the page) and `confidence: low | contested` (absent means normal; a `contested` page must explain the disagreement in its body, and contested is a state to exit, not a resting place: reconcile it). Mark claims you inferred rather than verified against the code with `(inferred)` inline; a page containing any carries `confidence: low`.
Every page starts with YAML frontmatter. All pages require `type`, `created`, `updated`, `description` (one accurate sentence; it is how pages are found without being opened), and `tags` (each tag must appear in `taxonomy.md`, which also describes the allowed page types; introducing a tag means adding it there in the same commit). Optional on any page: `sources` (mappings backing the page, each `- resource: /dir/page.md`, bundle-absolute; the OKF v0.2 shape) and `confidence: low | contested` (absent means normal; a `contested` page must explain the disagreement in its body, and contested is a state to exit, not a resting place: reconcile it). Mark claims you inferred rather than verified against the code with `(inferred)` inline; a page containing any carries `confidence: low`.

Extra required fields by type:

Expand Down
2 changes: 1 addition & 1 deletion codebase/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"inbox_warn_count": 10,
"inbox_warn_age_days": 14,
# Hot-core size guard: warn when CLAUDE.md exceeds this many lines.
"claude_md_max_lines": 130,
"claude_md_max_lines": 200,
# Every page tag must appear in this file (one `- tag — meaning` line each).
"taxonomy_file": "taxonomy.md",
# Contested pages untouched for this many days get flagged for reconcile.
Expand Down
38 changes: 36 additions & 2 deletions codebase/wikilint/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ def check_links_and_orphans(pages, report, root):
if target.rel != p.rel:
inbound[target.rel] += 1
for field in CONFIG["path_fields"] + CONFIG["edge_fields"]:
for value in p.field_list(field):
for value in p.path_list(field):
# External resources (URLs) are provenance, not wiki edges.
if URI_SCHEME_RE.match(value):
continue
target = resolve_link(value, by_stem, by_rel)
if target is None:
report.error(
Expand Down Expand Up @@ -606,8 +609,39 @@ def check_log(root, report):
)


def check_sources(pages, report):
"""OKF v0.2 sources shape (SPEC §5.1): each entry is a mapping whose
required resource names either an external URL or a bundle-absolute page
path. Plain-string entries (the pre-0.2 shape) still resolve as paths in
check_links_and_orphans, but warn until installs migrate."""
if not CONFIG["okf_conformance"]:
return
for p in pages:
for i, entry in enumerate(p.field_list("sources"), 1):
if isinstance(entry, dict):
resource = entry.get("resource", "")
if not resource:
report.error(
"sources", p.rel,
f"sources entry {i} has no resource "
"(required per entry, OKF v0.2 §5.1)",
)
elif not URI_SCHEME_RE.match(resource) and not resource.startswith("/"):
report.warning(
"sources", p.rel,
f"sources entry {i}: bundle paths should be "
f"bundle-absolute (/dir/page.md), got {resource!r}",
)
else:
report.warning(
"sources", p.rel,
f"sources entry {i} is a plain string; the OKF v0.2 shape "
"is '- resource: /dir/page.md'",
)


def check_okf(pages, report, root):
"""OKF v0.1 conformance: (1) every non-reserved .md parses as frontmatter,
"""OKF v0.2 conformance: (1) every non-reserved .md parses as frontmatter,
(2) with a non-empty type, (3) reserved files follow their structure.
Pages are skipped here: check_frontmatter is strictly stricter. log.md's
structure is owned by check_log, not re-validated here. raw/ is excluded
Expand Down
1 change: 1 addition & 0 deletions codebase/wikilint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def gather_report(root):
checks.check_mermaid(pages, report)
checks.check_adrs(pages, report, root)
checks.check_log(root, report)
checks.check_sources(pages, report)
checks.check_okf(pages, report, root)
check_index_drift(pages, report, root)
for extra in CONFIG["extra_checks"]:
Expand Down
Loading
Loading