From 65b1e422b3eec6849fe36344ba56df3588f9caa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kope=C4=87?= Date: Tue, 1 Sep 2026 20:50:36 +0200 Subject: [PATCH] Add decision log, scriptify OKF export and lint detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .agents/skills/ckb-decide/SKILL.md | 290 +++++++++ .agents/skills/ckb-export-okf/SKILL.md | 237 +++---- .../ckb-export-okf/scripts/export_okf.py | 529 +++++++++++++++ .agents/skills/ckb-ingest/SKILL.md | 14 + .agents/skills/ckb-init/SKILL.md | 182 +++++- .agents/skills/ckb-lint/SKILL.md | 172 +++-- .../skills/ckb-lint/scripts/lint_report.py | 607 ++++++++++++++++++ .agents/skills/ckb-retrieve/SKILL.md | 9 +- .gitignore | 2 + AGENTS.md | 19 +- MANUAL.md | 64 +- MANUAL.pl.md | 65 +- README.md | 42 +- README.pl.md | 45 +- VERSION | 2 +- wiki/decisions/.gitadd | 0 wiki/decisions/index.md | 25 + wiki/decisions/log.md | 16 + wiki/graph/index.md | 1 + wiki/index.md | 11 +- wiki/log.md | 26 + wiki/overview.md | 27 +- workload/2026-09-01_summary.md | 44 ++ 23 files changed, 2186 insertions(+), 243 deletions(-) create mode 100644 .agents/skills/ckb-decide/SKILL.md create mode 100644 .agents/skills/ckb-export-okf/scripts/export_okf.py create mode 100644 .agents/skills/ckb-lint/scripts/lint_report.py create mode 100644 wiki/decisions/.gitadd create mode 100644 wiki/decisions/index.md create mode 100644 wiki/decisions/log.md create mode 100644 workload/2026-09-01_summary.md diff --git a/.agents/skills/ckb-decide/SKILL.md b/.agents/skills/ckb-decide/SKILL.md new file mode 100644 index 0000000..a65b5c8 --- /dev/null +++ b/.agents/skills/ckb-decide/SKILL.md @@ -0,0 +1,290 @@ +--- +name: ckb-decide +description: Record a decision into wiki/decisions/ as a numbered decision record — what was decided, by whom, when, why, what it affects, and which earlier decision it supersedes or reverses — and answer questions about decisions already recorded ("what did we decide about X", "show decision 7", "which decisions are still open", "what changed the database choice"). Use when the user says "record a decision", "log a decision", "we decided ...", "ADR", "decision record", or asks what/when/why/by whom something was decided. Distinct from `ckb-ingest` (which turns raw source material into entity pages) and from `ckb-retrieve` (general KB questions — this skill is the decisions-scoped path, and hands off to it for anything wider). +--- + +# Decision log skill + +## Purpose + +A decision is a different kind of knowledge from an entity page. An entity +page describes what something *is*, and gets rewritten as understanding +improves. A decision record describes what was chosen *at a point in time*, +by whom, and why — and is never rewritten to reflect a later change of mind. +When the choice changes, a new decision supersedes the old one and both stay +on the record. That's the whole point: the value is in being able to ask +"why is it like this?" and get the reasoning, the people, and the date, not +just the current state. + +This skill owns both halves of that: + +- **Recording** — turning "we decided X" into a numbered, linked, logged + decision record under `wiki/decisions/`, with the supersession links wired + in both directions. +- **Looking up** — answering questions about decisions already recorded, + scoped to `wiki/decisions/` rather than searching the whole wiki. + +It writes only inside `wiki/` (`wiki/decisions/`, `wiki/decisions/index.md`, +`wiki/decisions/log.md`, `wiki/graph/edges.json`, and a pointer line in +`wiki/log.md`). It never edits an existing decision's substance — see +Rule: decisions are append-only, below. + +## Trigger phrases + +**Recording:** +- "record a decision" / "log a decision" / "add a decision record" / "write an ADR" +- "we decided ..." / "we've agreed to ..." / "the call was ..." +- "we're reversing ..." / "that supersedes decision N" + +**Looking up:** +- "what did we decide about X" / "why do we ... ?" (when the answer is a decision) +- "show decision 7" / "show me D-0007" +- "which decisions are still open" / "what's proposed but not accepted" +- "who decided X" / "when did we decide X" +- "what decisions affect " +- "what superseded decision N" / "what changed the X decision" + +For a question that only *touches* decisions as part of a wider answer, use +`ckb-retrieve` instead and let it pull decision pages in as one source +among many. This skill is for when decisions themselves are the subject. + +## The decision record format + +One page per decision at `wiki/decisions/NNNN-short-slug.md`, numbered +sequentially from `0001`. Numbers are never reused and never renumbered — a +superseded or reversed decision keeps its number and its page. + +```markdown +--- +type: decision +tldr: One sentence stating the decision itself, not the topic. +status: accepted +decided_on: 2026-09-01 +decided_by: Alice Smith, Bob Jones +affects: /wiki/entities/billing-service.md, /wiki/projects/index.md +review_on: 2027-03-01 +supersedes: /wiki/decisions/0003-use-mysql.md +confidence: 0.9 +quality: 0.8 +last_updated: 2026-09-01 +freshness_window_days: 365 +retention: high +--- + +# D-0007 — Use Postgres for the billing store + +**Status:** Accepted · **Decided:** 2026-09-01 · **Deciders:** [[Alice Smith]] / [Alice Smith](/wiki/entities/alice-smith.md), [[Bob Jones]] / [Bob Jones](/wiki/entities/bob-jones.md) + +## Context + +What forced a choice. The problem, the constraints, what was true at the +time. Written so it still makes sense to someone reading it in two years +with none of the surrounding conversation. + +## Decision + +What was actually decided, stated plainly and in the active voice. + +## Rationale + +Why this option won. The reasoning that would have to change for the +decision to be worth revisiting. + +## Consequences + +What follows — what this commits us to, what it rules out, what work it +creates. Both the good and the costly. + +## Alternatives considered + +What else was on the table and why each was not chosen. A decision record +without this is much less useful on re-reading: it's the part that stops +the same option being re-proposed every six months. + +## Supersession + +Supersedes [[D-0003]] / [D-0003](/wiki/decisions/0003-use-mysql.md) — MySQL +was chosen before the reporting requirements landed. + +## Sources + +Where this came from — a meeting, a thread, a `raw/archive/` file, a ticket. +``` + +### Field reference + +| Field | Required | Notes | +|---|---|---| +| `type` | yes | always `decision` | +| `tldr` | yes | the decision itself in one sentence ("Billing uses Postgres"), not the topic ("database choice") — this is what shows in the index and in search results | +| `status` | yes | `proposed` / `accepted` / `rejected` / `superseded` / `reversed` — vocabulary defined in `wiki/decisions/index.md` | +| `decided_on` | yes for `accepted`/`rejected`/`reversed` | `YYYY-MM-DD`, the date the call was made. Distinct from `last_updated`, which is when the *page* last changed. A `proposed` decision may have no `decided_on` yet. | +| `decided_by` | yes when known | comma-separated names, kept flat and plain so tooling can read it. The body's **Deciders** line carries the dual-links to entity pages. If genuinely unknown, write `unknown` rather than omitting the field — "we don't know who decided this" is itself worth recording. | +| `affects` | no | comma-separated project-root-absolute wiki paths this decision constrains. Cheap way to answer "what decisions touch X" without walking the graph. | +| `review_on` | no | `YYYY-MM-DD` to revisit. `ckb-lint` reports these once the date passes. | +| `supersedes` / `superseded_by` | when relevant | project-root-absolute path to the other decision. **Always set both sides** (see below). | +| `confidence`, `quality`, `last_updated`, `freshness_window_days`, `retention` | as usual | standard page schema. Decisions default to `freshness_window_days: 365` and `retention: high` — a decision record doesn't rot the way a status page does, and it should survive a retention sweep. | + +## How to run this skill — recording + +### Step 1 — Collect the facts, ask only for what's missing + +Take everything the user already said at face value; don't re-ask for it. +Then check what's missing against this list, in priority order: + +1. **The decision itself** — what was chosen. Without this there's nothing to record. +2. **Who decided** — names. This is the field users most often leave out and most often want later. +3. **When** — a date. "Today" is fine; "last Tuesday's architecture review" is fine, resolve it to a date. +4. **Why** — the rationale, and the alternatives that lost. +5. **Whether it changes an existing decision** — see Step 2. + +Ask for the missing ones in a **single** `AskUserQuestion` round rather than +an interview — this is a recording task, not a discovery interview. If the +user is clearly mid-flow and wants it written down now, record what you have, +mark the gaps explicitly in the page body (`## Rationale` → *"Not captured at +recording time."*), and say which fields you left open so they can fill them +in later. A decision recorded with gaps beats a decision not recorded. + +Do not invent context, rationale, or consequences. If the user gave you one +sentence, the record is one sentence plus the metadata — a fabricated +`## Consequences` section is worse than an absent one, because a later reader +can't tell it wasn't real. + +### Step 2 — Check whether it supersedes anything + +Before writing, read `wiki/decisions/index.md` and scan for a decision on the +same subject. If one exists and is still `accepted`: + +- Confirm with the user that the new decision replaces it (don't assume — two + decisions can coexist on the same subject at different scopes). +- If it replaces it outright, set `supersedes` on the new page and, on the + old page, set `superseded_by` **and** change its `status` to `superseded`. +- If it undoes it and returns to the prior state, use the same two links but + set the old page's status to `reversed` instead. +- Never edit anything else on the old page. Its context, rationale, and + consequences stay exactly as they were written — that's the historical + record. + +Both directions must be set. A one-sided supersession link is a lint finding, +and it breaks the "what changed this?" lookup in the other direction. + +### Step 3 — Allocate the number and write the page + +The next number is the highest existing `NNNN` in `wiki/decisions/` plus one, +zero-padded to four digits — never reuse a number, even if the highest-numbered +decision was rejected or superseded. Slug from the decision itself, not the +topic: `0007-use-postgres-for-billing.md`, not `0007-database.md`. + +Write the page using the format above. Link deciders to their entity pages +where those pages exist (dual-linked, per Rule C). Where a decider has no +entity page, write the name as plain text and mention the gap in your report +— creating person pages is `ckb-ingest`'s job, not this skill's. + +### Step 4 — Wire it into the index and graph + +- **`wiki/decisions/index.md`** — add a bullet in number order: + `* **[Accepted]** [D-0007 — Use Postgres for the billing store](0007-use-postgres-for-billing.md) — ` + and remove the "no decisions recorded yet" placeholder once there's a first + entry. When a decision's status changes (Step 2), update its index line too. +- **`wiki/graph/edges.json`** — add the edges this decision creates: + `decided_by` (decision → person page), `affects` (decision → each page named + in `affects`), and `supersedes` (new decision → old decision) where + applicable. Skip any edge whose target page doesn't exist rather than + pointing at a page you'd have to invent. + +### Step 5 — Log it, then report + +Log the change in **`wiki/decisions/log.md`** (not `wiki/log.md`) using +Rule B's format, and add a single pointer line to `wiki/log.md`: +`- See wiki/decisions/log.md for decision-record changes on this date.` +Per the Recursive Index & Log Convention, each change gets exactly one home +log — don't write the full entry in both. + +Then tell the user: the number and title assigned, the fields you filled, +any fields left open, what it superseded (and that the old page's status was +updated), which deciders had no entity page, and the standard reminder that +this is on disk but not committed — "say 'sync changes' when you want it +pushed." + +## How to run this skill — looking up + +### Step 1 — Start at the decisions index + +Read `wiki/decisions/index.md` first. It carries every decision's number, +title, status, and one-line summary — enough to answer "which decisions are +open", "what's been decided about X", and "which decision covers Y" without +opening a single page. + +### Step 2 — Open the pages that actually matter, and read them fully + +For a specific decision, read the whole page — a decision's `tldr` states the +choice but not the reasoning, and "why" is usually the real question. For a +subject-scoped question ("what have we decided about billing?"), open every +decision whose `affects` or body mentions the subject, including superseded +ones. + +**Superseded decisions are part of the answer, not noise.** "We use Postgres, +and before that MySQL, changed in September because of reporting" is the +useful answer; "we use Postgres" is the impoverished one. Follow +`supersedes`/`superseded_by` chains in both directions and present the +history in order. + +### Step 3 — Verify against the source before answering + +Apply `ckb-retrieve`'s standing rule: a decision page's `## Sources` section +points at where the decision came from. When the answer hinges on detail +beyond what the page states — exact wording, a number, who was actually in +the room — follow the source rather than paraphrasing the paraphrase. + +### Step 4 — Answer with the metadata attached + +A decision answer is incomplete without **who** and **when** — lead with the +decision, then attribute it. State the status plainly, especially when it +isn't `accepted`: an answer built on a `proposed` or `superseded` decision +must say so in the same breath, or the reader will act on something that +isn't in force. + +If nothing is recorded on the subject, say so plainly and offer to record one +now — don't reconstruct a decision from surrounding wiki content and present +it as if it were on the record. If the question revealed a genuine gap, that's +a `wiki/query-gaps.md` entry (`ckb-retrieve` owns that file). + +## Rule: decisions are append-only + +The only edits this skill makes to an *existing* decision page are: +`status`, `superseded_by`, `last_updated`, and — when the user is explicitly +correcting a recording error rather than changing their mind — the factual +metadata fields. Context, Decision, Rationale, Consequences, and Alternatives +are never rewritten to match a later view. + +When the user says "actually we changed our mind about D-0003", that is a new +decision superseding D-0003, not an edit to D-0003. Say so and record it that +way. The exception is a genuine transcription error ("I said Alice, it was +actually Anna") — fix that in place and note the correction in the log entry. + +## Edge cases + +- **No `wiki/decisions/` directory yet** — create it along with `index.md` + and `log.md` (using the scaffold this template ships), then record the + decision as `0001-…`. +- **The user describes a decision that was already recorded** — don't create + a duplicate. Point at the existing record and ask whether they want to + supersede it, correct it, or leave it. +- **A decision with no clear decider** ("we just kind of settled on it") — + record `decided_by: unknown` rather than guessing or attributing it to + whoever is in the room. Unattributed is a fact; misattributed is a defect. +- **A decision that was never actually made** ("we should probably...") — + record it as `status: proposed` with no `decided_on`, or don't record it at + all. Ask which; a proposal filed as `accepted` is the most damaging failure + mode this skill has. +- **A decision that reverses a reversal** — normal chaining. Each link points + one step back; the chain is the history. +- **Bulk import of historical decisions** (a meeting-notes backlog) — that's + `ckb-ingest`'s job for the extraction, then this skill's format for the + pages. Record them in chronological order so the numbers run in the same + order as the decisions. + +--- + +*Licensed under the Apache License, Version 2.0 — see [LICENSE](../../../LICENSE) +at the repository root, or .* diff --git a/.agents/skills/ckb-export-okf/SKILL.md b/.agents/skills/ckb-export-okf/SKILL.md index cfa9c24..4c196d2 100644 --- a/.agents/skills/ckb-export-okf/SKILL.md +++ b/.agents/skills/ckb-export-okf/SKILL.md @@ -17,9 +17,16 @@ dual-linking, the cascade layers). This is a one-way, on-demand export — `wiki/` stays the authoritative source; `outputs/okf/` is always a derived artifact of it, never edited by hand and never fed back in. +The whole transform — frontmatter remapping, link rewriting, index and log +regeneration, conformance validation — is done by a deterministic Python +script, not by reading and rewriting every page by hand. The mapping is a +fixed ruleset over a wiki that will keep growing, and a mechanical transform +like this belongs in code, not in per-page model reasoning. Nothing in this +skill needs an LLM to run correctly; the model's job is to invoke the script +and relay its report. + This skill only runs when explicitly invoked — it is deliberately not part -of the always-loaded `CLAUDE.md`/`AGENTS.md` Ingest/Lint workflows, so its -mapping ruleset doesn't tax every session's context. +of the always-loaded `CLAUDE.md`/`AGENTS.md` Ingest/Lint workflows. ## Trigger phrases @@ -31,154 +38,120 @@ Use this skill when the user says things like: ## How to run this skill -### Step 1 — Read the source tree +### Step 1 — Run the script -Read every file under `wiki/` (the whole tree, including `entities/`, -`graph/`, and any other subdirectories present). Note which filenames are -`index.md` or `log.md` at any depth (these are OKF's two reserved names) — -everything else is a concept document. +From the repository root: -### Step 2 — Clear and rebuild `outputs/okf/` +```bash +python3 "/scripts/export_okf.py" +``` -`outputs/okf/` is a pure, fully-regenerated build artifact. Delete its -current contents (if any) and rebuild it from scratch by mirroring `wiki/`'s -directory structure 1:1 under `outputs/okf/`. Never hand-patch an existing -export — always regenerate the whole bundle, so it can't accumulate stale -files left over from wiki pages that were since renamed or removed. +Resolve `` to this skill's own directory. Flags: -Do not touch anything outside `outputs/okf/`. Do not run any `git` -commands — regenerating files is this skill's job; staging and committing -the result is a separate, explicit action left to the user. +- `--check` — build into a temporary directory, run the conformance checks, + print the report, and write nothing to `outputs/`. Use this when the user + wants to know whether the wiki *would* export cleanly without touching the + working tree (e.g. as part of a review, or before a lint pass). +- `--out DIR` — write somewhere other than `outputs/okf` (rarely needed). -### Step 3 — Transform concept documents (every file that isn't `index.md`/`log.md`) +The script deletes and rebuilds the output directory on every run, so the +bundle can never accumulate stale files from pages that were since renamed +or removed. It touches nothing outside the output directory and runs no +`git` commands. -For each such file, rewrite its frontmatter using this field mapping: +The exit code is `0` when the generated bundle conforms and `1` when it does +not — the report always prints either way, so read it rather than relying on +the exit code alone. + +### Step 2 — Relay the script's report + +The script prints, and you should summarize back to the user: + +- Concept documents exported, and `index.md`/`log.md` files regenerated. +- Wikilinks stripped and `/wiki/` links rewritten to bundle-root paths. +- How many `linked/`/`libs/` cross-cascade references were left unconverted + — those won't resolve if the bundle is ever moved or shipped standalone, + which is spec-compliant (OKF tolerates broken links) but worth stating. +- Any `SOURCE ISSUE:` lines. These are problems in `wiki/` itself, not in + the export — most commonly a page with no `type`, which the bundle exports + as `type: unknown`. Surface them; the fix belongs in the source wiki (run + `ckb-lint`), not in the export. +- Any `NONCONFORMANT:` lines. These are bugs in the export — an + intra-bundle link that doesn't resolve, an index with the wrong + frontmatter shape, a malformed log header. Do not report the run as + successful when these appear; investigate the script rather than + hand-patching `outputs/okf/`. +- A reminder that `outputs/okf/` has been regenerated on disk but not staged + or committed — that's a separate step for the user when they're ready to + publish the update. (`outputs/okf` is gitignored by default in this + template, so "publish" usually means copying the bundle elsewhere rather + than committing it in place.) + +## What the transform does (for context, not something you need to re-derive) + +**Concept documents** (every `.md` that isn't `index.md`/`log.md`, including +`overview.md`, `error-book.md`, and `query-gaps.md` — only `index.md` and +`log.md` are OKF reserved names): | wiki/ field | OKF output field | Rule | |---|---|---| -| `type` | `type` | passthrough (required on both sides — every wiki page should already have one; if a page is somehow missing it, use `type: unknown` and flag it in the final report rather than skipping the file) | -| `resource` | `resource` | passthrough | +| `type` | `type` | passthrough; a page with no `type` exports as `unknown` and is reported as a source issue | +| *(derive)* | `title` | the first `# H1` in the body, else the slugified filename (`foo-bar.md` → "Foo Bar") | | `tldr` | `description` | rename | -| `last_updated` | `timestamp` | passthrough as-is (bare `YYYY-MM-DD` is a valid ISO 8601 date — do not fabricate a time-of-day that was never recorded) | -| *(none — derive)* | `title` | the first `# H1` heading in the body; if there is none, slugify the filename (e.g. `foo-bar.md` → "Foo Bar") | -| `confidence`, `quality`, `retention`, `supersedes`, `superseded_by`, `freshness_window_days` | same key names | passthrough unchanged, as OKF extension fields — OKF requires consumers to tolerate unrecognized keys, so these ride along as-is | -| `kb_schema_version` (only ever present on `wiki/index.md`) | *(omit)* | OKF's root `index.md` frontmatter is spec-limited to `okf_version` only; this key has no valid home in the bundle and is dropped, not relocated | -| *(none)* | `tags` | omit — there is no source field to derive it from; do not fabricate | +| `resource` | `resource` | passthrough | +| `last_updated` | `timestamp` | passthrough as-is (bare `YYYY-MM-DD` is valid ISO 8601 — no time-of-day is fabricated) | +| `confidence`, `quality`, `retention`, `supersedes`, `superseded_by`, `freshness_window_days` | same keys | passthrough as OKF extension fields, which consumers must tolerate | +| `kb_schema_version` | *(dropped)* | the root `index.md`'s frontmatter is spec-limited to `okf_version`; this key has no valid home in the bundle | +| *(none)* | `tags` | omitted — no source field to derive it from | -Then rewrite the body's links: +**Links:** the `[[Wikilink]]` half of every dual-link is dropped, keeping the +markdown half (a bare wikilink with no markdown twin degrades to its plain +label). Root-absolute `/wiki/entities/foo.md` becomes `/entities/foo.md`, +since the bundle root is `outputs/okf/`, not the repo root. Relative links +need no change — the export mirrors `wiki/`'s tree exactly. +`linked//...` and `libs//...` references are left untouched and +counted. -1. **Strip wikilinks.** Every dual-link in the source looks like - `[[Page Name]] / [Page Name](path.md)`. Delete the `[[...]]` half - (and the ` / ` separator if present), keep only the - `[text](path.md)` half. OKF has no wikilink concept. -2. **Rewrite repo-root-absolute intra-wiki links.** A link like - `/wiki/entities/foo.md` becomes `/entities/foo.md` — the OKF bundle - root is `outputs/okf/`, not the repo root, so the leading `/wiki` - segment must be stripped. Plain relative links (e.g. - `../entities/foo.md`) need no change, since the export mirrors - `wiki/`'s tree exactly. -3. **Leave `linked//...` and `libs//...` references - untouched.** These point outside `wiki/` and outside any - self-contained bundle — converting or inlining them is out of scope. - OKF explicitly tolerates broken links, so leaving these as - unresolvable-outside-the-repo references is spec-compliant. Count - how many of these you leave untouched, for the final report. +**Indexes:** regenerated from the tree rather than transcribed from the +source, so they can't drift. The root `index.md` carries only +`okf_version: "0.1"` (the one documented exception to "index.md has no +frontmatter"); nested indexes carry none. Each body is an H1 plus a flat +`* [Title](path) - description` bullet list of that directory's direct +children, sorted by path, with each page's own `description` as the +description text. The source's "Use when" column and prose sections are +dropped — they are Claude-agent lazy-loading optimizations with no meaning +to a generic OKF consumer. An empty list is spec-valid. -### Step 4 — Regenerate `index.md` files +**Logs:** entries are regrouped under `## YYYY-MM-DD` headers (date only, +newest date first) as `* **Verb**: [affected files] — description (source: ...)`. +`CREATE` → Creation, `UPDATE` → Update, `DELETE` → Deprecation, anything else +→ Update. The `- **Source:**` line rides along as the trailing parenthetical +rather than being dropped — it's provenance worth keeping. The `---` +separators between entries are dropped; they aren't part of OKF's log format. -**Root `outputs/okf/index.md`:** frontmatter contains *only* -`okf_version: "0.1"` — this is the one documented exception to "index.md -has no frontmatter" in the OKF spec. Body is a flat bullet list, one line -per linked page or subdirectory, in the form -`* [Title](path) - one-line description` (reuse each page's `description`, -i.e. the renamed `tldr`, as the description text). Drop the source -`wiki/index.md`'s "Use when" column entirely — it's a Claude-agent-only -lazy-loading optimization with no meaning to a generic OKF consumer — and -drop any prose sections (like the source's "## Entity Pages" paragraph). - -**Every nested `index.md`** (`entities/index.md`, `graph/index.md`, and -any future topic-folder index): no frontmatter, ever. Strip the source's -explanatory prose and italic placeholder notes (e.g. -"*(No entities yet — populated on the next ingest.)*"), keeping only the -bullet list — an empty list is spec-valid if the directory has no pages -yet. - -### Step 5 — Regenerate `log.md` files - -For each `log.md` in the source tree (root and any subdirectory), reformat -its entries into OKF's convention: group entries by date under -`## YYYY-MM-DD` headers (date only, no time, newest date first), each -entry as a bullet: - -``` -* **Verb**: description with [links](/path/to.md) (source: original source text). -``` - -Map the source's `ACTION TYPE` to an OKF verb: `CREATE` → Creation, -`UPDATE` → Update, `DELETE` → Deprecation, `RESTRUCTURE` → Update (fallback -for anything else). Fold the source entry's `- **Source:**` line in as the -trailing parenthetical shown above rather than dropping it — it's useful -provenance information and OKF's format has room for free text after the -verb. Drop the `---` horizontal-rule separators between entries; they are -not part of OKF's log format. - -### Step 6 — Handle non-reserved special pages - -`wiki/error-book.md` and `wiki/overview.md` are not OKF reserved -filenames (only `index.md` and `log.md` are) — export them as ordinary -concept documents using the Step 3 rules like any other page. They should -already carry `type`/`tldr`/`last_updated` frontmatter; if you ever find -one that doesn't, that's a lint problem in the source wiki — flag it in -the report rather than silently patching the export. - -### Step 7 — Validate the output bundle - -Before reporting done, re-check the *generated* `outputs/okf/` bundle -against OKF's own conformance criteria (the same shape of conformance check -`ckb-lint` runs against the source wiki): - -- Every non-reserved `.md` file has frontmatter with a non-empty `type`. -- The root `index.md`'s frontmatter contains only `okf_version` (or is - empty); every nested `index.md` has no frontmatter at all. -- Every `log.md` entry matches the `## YYYY-MM-DD` header pattern. -- Every intra-bundle link (i.e. every link you did NOT leave untouched in - Step 3.3) resolves to a file that actually exists in `outputs/okf/`. If - the rewrite in Step 3.2 produced a link that doesn't resolve, that's a - bug in this export, not an acceptable "broken link" — fix it before - reporting done, don't just note it as a warning. - -### Step 8 — Report - -Tell the user: -- How many concept documents were exported. -- How many `index.md`/`log.md` files were regenerated. -- How many `linked/`/`libs/` cross-cascade references were left - unconverted (Step 3.3), since those won't resolve if the bundle is ever - moved or shipped standalone. -- Any conformance issues found in Step 7 and whether they were fixed. -- A reminder that `outputs/okf/` has been regenerated on disk but not - staged or committed — that's a separate step for the user to take when - ready to publish the update. +**Validation** runs against the generated bundle before the report prints: +every non-reserved page has a non-empty `type`; the root index has only +`okf_version` and nested indexes have no frontmatter; every `log.md` header +matches `## YYYY-MM-DD`; and every intra-bundle link resolves to a file that +exists (cross-cascade `linked/`/`libs/` references are exempt by design). ## Edge cases -- **Empty `wiki/entities/` or `wiki/graph/`** (as of writing, both are - empty): still regenerate their `index.md` as an empty bullet list under - `outputs/okf/entities/` and `outputs/okf/graph/` — an empty index is - spec-valid, don't skip the directory entirely. -- **A wiki page missing `type`:** per Step 3, use `type: unknown` and flag - it in the report — this indicates the source wiki itself failed lint's - conformance check, which is worth surfacing - to the user rather than quietly masking it in the export. -- **A future `wiki/archived/` directory:** export it like any other - subdirectory (mirror the structure, apply the same per-file rules) — - OKF has no notion of archival status; `retention`/`freshness_window_days` +- **Empty `wiki/entities/` or `wiki/graph/`:** their `index.md` is still + regenerated, as an H1 with an empty bullet list. An empty index is + spec-valid; the directory is never skipped. +- **A future `wiki//` subdirectory:** handled automatically — the + script discovers directories dynamically, mirrors them, and generates an + index for each. No script changes needed. +- **A future `wiki/archived/`:** exported like any other subdirectory. OKF + has no notion of archival status; `retention`/`freshness_window_days` already ride along as extension fields for any consumer that cares. -- **Re-running the skill with no wiki changes since the last run** should - produce byte-identical output — if you notice non-determinism (e.g. from - arbitrary ordering when listing directory entries), sort filenames - alphabetically wherever you're generating a bullet list or walking a - directory, so re-runs are stable. +- **Non-markdown files in `wiki/`** (e.g. `graph/edges.json`): copied + verbatim into the same relative position and listed in their directory's + index. Dotfiles (`.gitadd`) are skipped. +- **Re-running with no wiki changes:** produces byte-identical output — every + directory walk and generated list is sorted. If a run is ever + nondeterministic, that's a bug in the script, not expected behavior. --- diff --git a/.agents/skills/ckb-export-okf/scripts/export_okf.py b/.agents/skills/ckb-export-okf/scripts/export_okf.py new file mode 100644 index 0000000..755c2b3 --- /dev/null +++ b/.agents/skills/ckb-export-okf/scripts/export_okf.py @@ -0,0 +1,529 @@ +#!/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\[[^\]]*\]\([^)]*\))", drop_pair, body + ) + body = re.sub( + r"(?P\[[^\]]*\]\([^)]*\))\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\d{4}-\d{2}-\d{2})(?:[ T](?P