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>
This commit is contained in:
Michał Kopeć 2026-09-01 20:50:36 +02:00
parent 8174a54cee
commit 65b1e422b3
23 changed files with 2186 additions and 243 deletions

View file

@ -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 <project/system/person>"
- "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) — <tldr>`
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 <http://www.apache.org/licenses/LICENSE-2.0>.*

View file

@ -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 `wiki/` stays the authoritative source; `outputs/okf/` is always a derived
artifact of it, never edited by hand and never fed back in. 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 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 of the always-loaded `CLAUDE.md`/`AGENTS.md` Ingest/Lint workflows.
mapping ruleset doesn't tax every session's context.
## Trigger phrases ## Trigger phrases
@ -31,154 +38,120 @@ Use this skill when the user says things like:
## How to run this skill ## 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/`, From the repository root:
`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.
### Step 2 — Clear and rebuild `outputs/okf/` ```bash
python3 "<skill-dir>/scripts/export_okf.py"
```
`outputs/okf/` is a pure, fully-regenerated build artifact. Delete its Resolve `<skill-dir>` to this skill's own directory. Flags:
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.
Do not touch anything outside `outputs/okf/`. Do not run any `git` - `--check` — build into a temporary directory, run the conformance checks,
commands — regenerating files is this skill's job; staging and committing print the report, and write nothing to `outputs/`. Use this when the user
the result is a separate, explicit action left to 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 | | 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) | | `type` | `type` | passthrough; a page with no `type` exports as `unknown` and is reported as a source issue |
| `resource` | `resource` | passthrough | | *(derive)* | `title` | the first `# H1` in the body, else the slugified filename (`foo-bar.md` → "Foo Bar") |
| `tldr` | `description` | rename | | `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) | | `resource` | `resource` | passthrough |
| *(none — derive)* | `title` | the first `# H1` heading in the body; if there is none, slugify the filename (e.g. `foo-bar.md` → "Foo Bar") | | `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 key names | passthrough unchanged, as OKF extension fields — OKF requires consumers to tolerate unrecognized keys, so these ride along as-is | | `confidence`, `quality`, `retention`, `supersedes`, `superseded_by`, `freshness_window_days` | same keys | passthrough as OKF extension fields, which consumers must tolerate |
| `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 | | `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` | omit — there is no source field to derive it from; do not fabricate | | *(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/<name>/...` and `libs/<name>/...` references are left untouched and
counted.
1. **Strip wikilinks.** Every dual-link in the source looks like **Indexes:** regenerated from the tree rather than transcribed from the
`[[Page Name]] / [Page Name](path.md)`. Delete the `[[...]]` half source, so they can't drift. The root `index.md` carries only
(and the ` / ` separator if present), keep only the `okf_version: "0.1"` (the one documented exception to "index.md has no
`[text](path.md)` half. OKF has no wikilink concept. frontmatter"); nested indexes carry none. Each body is an H1 plus a flat
2. **Rewrite repo-root-absolute intra-wiki links.** A link like `* [Title](path) - description` bullet list of that directory's direct
`/wiki/entities/foo.md` becomes `/entities/foo.md` — the OKF bundle children, sorted by path, with each page's own `description` as the
root is `outputs/okf/`, not the repo root, so the leading `/wiki` description text. The source's "Use when" column and prose sections are
segment must be stripped. Plain relative links (e.g. dropped — they are Claude-agent lazy-loading optimizations with no meaning
`../entities/foo.md`) need no change, since the export mirrors to a generic OKF consumer. An empty list is spec-valid.
`wiki/`'s tree exactly.
3. **Leave `linked/<name>/...` and `libs/<name>/...` 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.
### 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* **Validation** runs against the generated bundle before the report prints:
`okf_version: "0.1"` — this is the one documented exception to "index.md every non-reserved page has a non-empty `type`; the root index has only
has no frontmatter" in the OKF spec. Body is a flat bullet list, one line `okf_version` and nested indexes have no frontmatter; every `log.md` header
per linked page or subdirectory, in the form matches `## YYYY-MM-DD`; and every intra-bundle link resolves to a file that
`* [Title](path) - one-line description` (reuse each page's `description`, exists (cross-cascade `linked/`/`libs/` references are exempt by design).
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.
## Edge cases ## Edge cases
- **Empty `wiki/entities/` or `wiki/graph/`** (as of writing, both are - **Empty `wiki/entities/` or `wiki/graph/`:** their `index.md` is still
empty): still regenerate their `index.md` as an empty bullet list under regenerated, as an H1 with an empty bullet list. An empty index is
`outputs/okf/entities/` and `outputs/okf/graph/` — an empty index is spec-valid; the directory is never skipped.
spec-valid, don't skip the directory entirely. - **A future `wiki/<newtopic>/` subdirectory:** handled automatically — the
- **A wiki page missing `type`:** per Step 3, use `type: unknown` and flag script discovers directories dynamically, mirrors them, and generates an
it in the report — this indicates the source wiki itself failed lint's index for each. No script changes needed.
conformance check, which is worth surfacing - **A future `wiki/archived/`:** exported like any other subdirectory. OKF
to the user rather than quietly masking it in the export. has no notion of archival status; `retention`/`freshness_window_days`
- **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`
already ride along as extension fields for any consumer that cares. already ride along as extension fields for any consumer that cares.
- **Re-running the skill with no wiki changes since the last run** should - **Non-markdown files in `wiki/`** (e.g. `graph/edges.json`): copied
produce byte-identical output — if you notice non-determinism (e.g. from verbatim into the same relative position and listed in their directory's
arbitrary ordering when listing directory entries), sort filenames index. Dotfiles (`.gitadd`) are skipped.
alphabetically wherever you're generating a bullet list or walking a - **Re-running with no wiki changes:** produces byte-identical output — every
directory, so re-runs are stable. directory walk and generated list is sorted. If a run is ever
nondeterministic, that's a bug in the script, not expected behavior.
--- ---

View file

@ -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<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())

View file

@ -74,6 +74,20 @@ or cc'd on a thread is not expertise, and don't infer ownership from job
title alone. Both are optional, like every other edge type; an absent title alone. Both are optional, like every other edge type; an absent
edge is better than a fabricated one. edge is better than a fabricated one.
**Decisions are not entity pages.** When the material records a choice that
was actually made — a call with a rationale, a date, and someone who made it
— that belongs in `wiki/decisions/` as a numbered decision record, not in
`wiki/entities/`. Use `ckb-decide`'s format (it owns the field set, the
numbering, and the two-sided supersession links) and record them in
chronological order so decision numbers run in the same order as the
decisions. Two things worth being strict about, because ingest is where they
go wrong: a proposal that was discussed but not settled is
`status: proposed`, never `accepted`; and where the source doesn't say who
decided, `decided_by: unknown` beats attributing it to whoever was loudest in
the notes. If the material only *references* a decision without recording
it ("as agreed last quarter, we use Postgres"), that's a query-gap candidate,
not a decision record — you don't have the rationale or the date.
For recurring teams, clients, systems, or initiatives, consider whether a For recurring teams, clients, systems, or initiatives, consider whether a
plain project scope page under `wiki/projects/<name>.md` would make future plain project scope page under `wiki/projects/<name>.md` would make future
queries easier to route. A scope page should list when to use it, included queries easier to route. A scope page should list when to use it, included

View file

@ -1,23 +1,30 @@
--- ---
name: ckb-init name: ckb-init
description: Bootstrap a brand-new Cascade Knowledge Base - the same directory structure, AGENTS.md/CLAUDE.md system prompt, full default skill set, LICENSE/VERSION, and empty wiki/ scaffold as this project - inside a target folder (typically empty, or a new project that doesn't have one yet). Use when the user asks to "set up a new wiki like this one", "initialize a new cascade KB", "bootstrap a wiki here", "create a knowledge base with this schema", or calls it a "wiki initializer". Do not confuse with a generic `init` skill that documents an existing codebase - this one creates the Cascade KB pattern itself, empty, ready for its first ingest. Distinct from `ckb-upgrade`, which catches an *existing* populated KB up with template changes rather than bootstrapping a new one. description: Bootstrap a brand-new Cascade Knowledge Base - the same directory structure, AGENTS.md/CLAUDE.md system prompt, full default skill set, LICENSE/VERSION, and empty wiki/ scaffold as this project - inside a target folder, sourcing the template either from this repo's own files or by pulling a fresh clone of the canonical ckb repo (or a user-supplied fork/mirror URL) into a scratch folder, so it works even when run outside an existing KB. The target folder is typically empty, or a new project that doesn't have a KB yet. Use when the user asks to "set up a new wiki like this one", "initialize a new cascade KB", "bootstrap a wiki here", "create a knowledge base with this schema", "pull the ckb repo into this folder", "clone the wiki template into <dir>", or calls it a "wiki initializer". Do not confuse with a generic `init` skill that documents an existing codebase - this one creates the Cascade KB pattern itself, empty, ready for its first ingest. Distinct from `ckb-upgrade`, which catches an *existing* populated KB up with template changes rather than bootstrapping a new one.
--- ---
# Cascade KB init skill # Cascade KB init skill
## Purpose ## Purpose
Copy this project's Cascade Knowledge Base *schema* - not its content - into Copy the Cascade Knowledge Base *schema* - not its content - into a new
a new target folder: the directory structure, the `AGENTS.md`/`CLAUDE.md` target folder: the directory structure, the `AGENTS.md`/`CLAUDE.md`
system prompt that defines how the KB behaves, the full default skill set, system prompt that defines how the KB behaves, the full default skill set,
`LICENSE` and `VERSION`, the generic `README`/`MANUAL` docs, and the empty `LICENSE` and `VERSION`, the generic `README`/`MANUAL` docs, and the empty
`wiki/` scaffold (routing table, overview, log, error book, entity/graph `wiki/` scaffold (routing table, overview, log, error book, entity/graph
indexes). The result is a new, empty KB that behaves exactly like this one, indexes). The result is a new, empty KB that behaves exactly like this one,
ready for its first `raw/inbox/` drop and "Ingest." ready for its first `raw/inbox/` drop and "Ingest."
This is a one-way copy from this repo's own template files into a The template it copies from is either this repo's own files or a fresh
different folder. It never reads or writes anything in this repo's `raw/`, shallow clone of the canonical template repo pulled into a scratch folder
`wiki/entities/`, `wiki/graph/edges.json`, or `outputs/` - those hold this (Step 3) - the latter is what makes this skill usable from anywhere, not
only from inside an existing KB, and what "pull the repo into a folder to
set up a wiki" means in practice. Either way it is a one-way copy into a
different folder, and the clone is scratch: it never becomes the new KB's
`.git/`.
It never reads or writes anything in the template source's `raw/`,
`wiki/entities/`, `wiki/graph/edges.json`, or `outputs/` - those hold that
project's actual accumulated knowledge, which is exactly what should *not* project's actual accumulated knowledge, which is exactly what should *not*
travel into a fresh KB. (For catching an *already-populated* KB up with travel into a fresh KB. (For catching an *already-populated* KB up with
newer template files without losing its accumulated content, see newer template files without losing its accumulated content, see
@ -30,6 +37,8 @@ folder.)
- "set up a new wiki like this one" / "initialize a new cascade KB" - "set up a new wiki like this one" / "initialize a new cascade KB"
- "bootstrap a wiki here" / "create a knowledge base with this schema" - "bootstrap a wiki here" / "create a knowledge base with this schema"
- "wiki initializer" / "clone this KB structure into a new project" - "wiki initializer" / "clone this KB structure into a new project"
- "pull the ckb repo into `<dir>` and set up the wiki structure"
- "initialize a KB from `<url>`" / "get the latest template and bootstrap a KB"
## How to run this skill ## How to run this skill
@ -59,19 +68,78 @@ alongside them. Note any top-level name collisions (e.g. an existing
`wiki/` folder used for something else) and ask before touching those `wiki/` folder used for something else) and ask before touching those
specifically. specifically.
### Step 3 - Default skill set (no need to ask) ### Step 3 - Resolve the template source
Everything this skill copies (`AGENTS.md`, `LICENSE`, `VERSION`, the
README/MANUAL docs, `.gitignore`, the `wiki/` scaffold, the skill set) comes
from one *template source*. There are two, and they produce the same result:
**a) A fresh clone of the canonical template repo (default when this skill
runs outside a Cascade KB, and always available).** Pull the repo into a
scratch folder and copy out of it:
```bash
rm -rf <scratch>
git clone --depth 1 https://git.wierzbowa.cloud/michal/ckb.git <scratch>
```
`<scratch>` is `tmp/ckb-init-src` under the *current* KB when this skill
runs inside one (matching `ckb-upgrade`'s convention - that directory is
already gitignored), or a system temp path such as
`/tmp/ckb-init-src` when it doesn't.
Use a user-supplied URL instead if they named one ("initialize from
`<url>`") - a fork or an internal mirror is a legitimate template source.
If `git` isn't available or the clone fails (network, auth, unreachable
host), report the raw error; fall back to (b) if this skill is running
inside a Cascade KB, otherwise stop - there is nothing to copy from.
Clone into a scratch path *outside* the target folder, never into the
target itself. Cloning straight into the target would drag the template's
own git history, `.git/`, `workload/`, and any committed sample content
into the new KB; the new KB gets its own history in Step 11 instead. The
one exception is if the user explicitly says they want the template repo
itself checked out in place (i.e. "just clone ckb into `<dir>`") - that's a
plain `git clone`, not an init; do that, say so plainly, and skip the rest
of these steps.
**b) This repo's own working tree (default when this skill runs from inside
a Cascade KB and the user hasn't asked for a fresh pull).** Copy directly
from the current repo's files, exactly as described in the steps below.
This is the faster path and is what you want when the current KB is itself
the template being propagated - but it copies whatever local, possibly
uncommitted, template edits exist here. If the user says "pull the latest",
"from the remote", or "from upstream", use (a) instead.
If both are viable and the user hasn't indicated a preference, ask which
one - a stale local template and an upstream one that moved are a real
difference, not a formality.
For the rest of this skill, "this repo's `<path>`" means "`<path>` in the
resolved template source" - the steps are identical either way. Note the
source (URL + short commit hash, or "local working tree") for the Step 12
report, and delete the scratch clone once Step 10 has finished copying:
```bash
rm -rf <scratch>
```
### Step 4 - Default skill set (no need to ask)
The bare scaffold (directory structure + `AGENTS.md`/`CLAUDE.md` + empty The bare scaffold (directory structure + `AGENTS.md`/`CLAUDE.md` + empty
`wiki/` templates) is always included, and so is the full reusable KB `wiki/` templates) is always included, and so is the full reusable KB
skill set - these operate purely on the `wiki/` structure (or, for skill set - these operate purely on the `wiki/` structure (or, for
`ckb-init` and `ckb-upgrade` themselves, on the schema layer), so they `ckb-init` and `ckb-upgrade` themselves, on the schema layer), so they
carry over cleanly and are part of "the schema" as far as this skill is carry over cleanly and are part of "the schema" as far as this skill is
concerned. As of this writing, that's every skill under this repo's concerned. As of this writing, that's every skill under the template
`.agents/skills/`: source's `.agents/skills/`:
- `ckb-init` (this skill - a new KB can bootstrap further KBs of its own) - `ckb-init` (this skill - a new KB can bootstrap further KBs of its own)
- `ckb-upgrade` (lets the new KB catch up with template changes later) - `ckb-upgrade` (lets the new KB catch up with template changes later)
- `ckb-ingest` - `ckb-ingest`
- `ckb-decide`
- `ckb-retrieve`
- `ckb-index-external`
- `ckb-lint` - `ckb-lint`
- `ckb-sync-changes` - `ckb-sync-changes`
- `ckb-project-summary` - `ckb-project-summary`
@ -79,7 +147,7 @@ concerned. As of this writing, that's every skill under this repo's
- `ckb-export-starlight` - `ckb-export-starlight`
- `ckb-onboard-me` - `ckb-onboard-me`
- `ckb-teach-me` - `ckb-teach-me`
- `cbk-quiz` - `ckb-quiz`
Don't ask about any of these - just include them. Don't ask about any of these - just include them.
@ -94,9 +162,9 @@ that case, only include it if the user explicitly asks for it by name,
e.g. "also bring over `<skill-name>`." e.g. "also bring over `<skill-name>`."
Record the final skill list (the defaults above, plus anything explicitly Record the final skill list (the defaults above, plus anything explicitly
added) - this affects Steps 8 and 9. added) - this affects Steps 9 and 10.
### Step 4 - Create the directory structure ### Step 5 - Create the directory structure
Under the target folder, create: Under the target folder, create:
@ -109,17 +177,19 @@ raw/archive/
tmp/ tmp/
wiki/entities/ wiki/entities/
wiki/graph/ wiki/graph/
wiki/projects/
wiki/decisions/
workload/ workload/
``` ```
Git doesn't track empty directories, so every one of these - plus Git doesn't track empty directories, so every one of these - plus
`.agents/`, `.agents/skills/`, and `.claude/` created in Step 9 - needs a `.agents/`, `.agents/skills/`, and `.claude/` created in Step 10 - needs a
placeholder to survive a fresh `git init` and first commit. This repo's placeholder to survive a fresh `git init` and first commit. This repo's
own convention is an empty file named `.gitadd` in each directory (not own convention is an empty file named `.gitadd` in each directory (not
`.gitkeep`) - match that convention exactly, so a new KB's directory `.gitkeep`) - match that convention exactly, so a new KB's directory
listing looks identical to this one's. listing looks identical to this one's.
`libs/` and `tmp/` are gitignored per the schema (Step 8) except for their `libs/` and `tmp/` are gitignored per the schema (Step 9) except for their
own `.gitadd` placeholder, so they stay effectively empty. `linked/` gets own `.gitadd` placeholder, so they stay effectively empty. `linked/` gets
a `.gitadd` too but is *not* gitignored - it's meant to hold real a `.gitadd` too but is *not* gitignored - it's meant to hold real
symlinks, which git tracks natively (as a small blob storing the link symlinks, which git tracks natively (as a small blob storing the link
@ -127,7 +197,7 @@ target), so nothing extra is needed there. `raw/inbox/`, `raw/archive/`,
and `workload/` are meant to be tracked and start genuinely empty aside and `workload/` are meant to be tracked and start genuinely empty aside
from their placeholder. from their placeholder.
### Step 5 - Write `AGENTS.md` and the `CLAUDE.md` symlink ### Step 6 - Write `AGENTS.md` and the `CLAUDE.md` symlink
Copy this repo's `AGENTS.md` verbatim into the target as `AGENTS.md` - it Copy this repo's `AGENTS.md` verbatim into the target as `AGENTS.md` - it
is already fully generic (no project-specific content; it *is* the is already fully generic (no project-specific content; it *is* the
@ -135,7 +205,7 @@ schema definition). Then create `CLAUDE.md` in the target as a symlink to
`AGENTS.md`, matching this repo's own convention (one source of truth, `AGENTS.md`, matching this repo's own convention (one source of truth,
readable under either filename). readable under either filename).
### Step 6 - Write `LICENSE`, `VERSION`, and the generic docs ### Step 7 - Write `LICENSE`, `VERSION`, and the generic docs
Copy these verbatim from this repo - they're already fully generic (no Copy these verbatim from this repo - they're already fully generic (no
project-specific content, confirmed by having zero references to any project-specific content, confirmed by having zero references to any
@ -158,14 +228,16 @@ project that will accumulate its own content under its own ownership) -
don't silently carry over a copyright attribution that may not apply to don't silently carry over a copyright attribution that may not apply to
what the new KB is about to collect. what the new KB is about to collect.
### Step 7 - Write the empty `wiki/` scaffold ### Step 8 - Write the empty `wiki/` scaffold
Create these files in the target, using this repo's current versions as Create these files in the target, using this repo's current versions as
the template and stripping every reference to this project's actual the template and stripping every reference to this project's actual
content (Grant Thornton, Cloud Drift, specific entities, etc.) down to the content (Grant Thornton, Cloud Drift, specific entities, etc.) down to the
generic structure: generic structure:
- **`wiki/index.md`** - frontmatter with `kb_schema_version: "1.1"` only. - **`wiki/index.md`** - frontmatter with `kb_schema_version` only, set to
whatever value the template source's own `wiki/index.md` carries (do not
hard-code it here - it moves with the schema).
Body: the routing table with just its four fixed infrastructure rows Body: the routing table with just its four fixed infrastructure rows
(Overview, Log, Error Book, Entities, Graph) and no entity rows, plus the (Overview, Log, Error Book, Entities, Graph) and no entity rows, plus the
"## Entity Pages" section with its placeholder note. Use today's date "## Entity Pages" section with its placeholder note. Use today's date
@ -181,12 +253,23 @@ generic structure:
generic "Current graph coverage: (none yet)" line instead of this generic "Current graph coverage: (none yet)" line instead of this
repo's specific bullet list. repo's specific bullet list.
- **`wiki/graph/edges.json`** - `{"version": 1, "last_updated": "<today>", "edges": []}`. - **`wiki/graph/edges.json`** - `{"version": 1, "last_updated": "<today>", "edges": []}`.
- **`wiki/projects/index.md`** - header + placeholder note, no project
scopes yet.
- **`wiki/decisions/index.md`** - header, the status-vocabulary table, and a
placeholder note; no decisions yet. Copy the status vocabulary verbatim —
`ckb-decide` and `ckb-lint` both validate against it.
- **`wiki/decisions/log.md`** - header and explanation only, no entries.
- **`wiki/query-gaps.md`** - header + explanation only, no recorded gaps.
If the template source's `wiki/` holds scaffold files beyond this list,
carry them over the same way - empty, structure only. The list above is
what the schema requires, not a cap.
Do not carry over any entity pages, graph edges, log entries, or overview Do not carry over any entity pages, graph edges, log entries, or overview
content specific to this project - the whole point is an empty KB with the content specific to this project - the whole point is an empty KB with the
same shape. same shape.
### Step 8 - Write `.gitignore` ### Step 9 - Write `.gitignore`
Copy this repo's actual current `.gitignore` verbatim rather than Copy this repo's actual current `.gitignore` verbatim rather than
reconstructing it from memory - it uses a `<dir>/*` + `!<dir>/.gitadd` reconstructing it from memory - it uses a `<dir>/*` + `!<dir>/.gitadd`
@ -196,6 +279,16 @@ tracked shell but have their real contents ignored:
``` ```
libs/* libs/*
!libs/.gitadd !libs/.gitadd
!libs/*/
libs/*/*
!libs/*/source.yaml
!libs/*/index.md
!libs/*/log.md
!libs/*/entities/
!libs/*/graph/
# Per-user local override (e.g. source.local.yaml's access: write) — never shared, stays ignored
# by the libs/*/* catch-all above; listed explicitly for clarity, not because it changes behavior.
libs/*/*.local.yaml
tmp/* tmp/*
!tmp/.gitadd !tmp/.gitadd
outputs/starlight outputs/starlight
@ -204,8 +297,13 @@ outputs/teaching
.env .env
``` ```
(That `libs/` block is what keeps a connector-backed lib's `source.yaml`
and its agent-generated index tracked while ignoring everything else under
it - copy it as a unit. If the template source's `.gitignore` has since
changed, its version wins over the snapshot above.)
Since `ckb-export-starlight`, `ckb-export-okf`, and `ckb-teach-me` are all Since `ckb-export-starlight`, `ckb-export-okf`, and `ckb-teach-me` are all
in the default skill set (Step 3), their `outputs/` subfolders in the default skill set (Step 4), their `outputs/` subfolders
(`outputs/starlight`, `outputs/okf`, `outputs/teaching`) are gitignored by (`outputs/starlight`, `outputs/okf`, `outputs/teaching`) are gitignored by
default too - each exists to be ignored precisely because its skill is default too - each exists to be ignored precisely because its skill is
present by default. If a future skill set change ever drops one of those present by default. If a future skill set change ever drops one of those
@ -213,26 +311,49 @@ three skills from the default set, drop its matching ignore line too;
if the user explicitly adds a skill with its own `outputs/<x>` convention, if the user explicitly adds a skill with its own `outputs/<x>` convention,
ask whether it needs a similar ignore line. ask whether it needs a similar ignore line.
### Step 9 - Copy the skill set from Step 3 ### Step 10 - Copy the skill set from Step 4
Copy each skill's folder from this repo's `.agents/skills/<name>/` into the Copy each skill's folder from this repo's `.agents/skills/<name>/` into the
target's `.agents/skills/<name>/` unchanged (including each skill's own target's `.agents/skills/<name>/` unchanged (including each skill's own
license footer, and any support files like license footer, and any support files like
`ckb-export-starlight/scripts/export_starlight.py`) - the full default set `ckb-export-okf/scripts/export_okf.py`,
from Step 3, plus anything explicitly added. Then create `.claude/skills` `ckb-export-starlight/scripts/export_starlight.py`,
`ckb-lint/scripts/lint_report.py`) - the full default set
from Step 4, plus anything explicitly added. Then create `.claude/skills`
in the target as a symlink to `../.agents/skills`, matching this repo's in the target as a symlink to `../.agents/skills`, matching this repo's
convention - do this once, after copying the whole set, not per-skill. convention - do this once, after copying the whole set, not per-skill.
### Step 10 - Report ### Step 11 - Initialize the new KB's own git history (ask first)
The target is now a complete KB but has no history of its own - and if the
template came from a clone (Step 3a), it deliberately carries none of the
template's. Ask whether to initialize one:
```bash
git init
git add .
git commit -m "Initialize Cascade Knowledge Base from ckb template v<VERSION>"
```
Only do this if the target isn't already inside a git repo (`git rev-parse
--is-inside-work-tree` from the target) - if it is, say so and leave the
staging to the user rather than committing into someone else's repo. Don't
add a remote and don't push; that's `ckb-sync-changes`' job once the user
has a remote to point at.
### Step 12 - Report
Tell the user: Tell the user:
- The resolved target path. - The resolved target path.
- The template source used: the clone URL and short commit hash, or "this
repo's local working tree."
- The directory tree created. - The directory tree created.
- Whether `AGENTS.md`/`CLAUDE.md` were written or (per Step 2) skipped/merged. - Whether `AGENTS.md`/`CLAUDE.md` were written or (per Step 2) skipped/merged.
- Which skills were copied (the full default set, plus anything explicitly - Which skills were copied (the full default set, plus anything explicitly
added). added).
- The `VERSION` the new KB starts on, and what was decided for `LICENSE`'s - The `VERSION` the new KB starts on, and what was decided for `LICENSE`'s
copyright line. copyright line.
- Whether a git repo was initialized in the target, or why not.
- Next step: "Drop material into `raw/inbox/` and say 'Ingest' to populate the wiki for the first time." - Next step: "Drop material into `raw/inbox/` and say 'Ingest' to populate the wiki for the first time."
## Edge cases ## Edge cases
@ -246,6 +367,19 @@ Tell the user:
- **User wants only *some* of the wiki template files** (e.g. just the - **User wants only *some* of the wiki template files** (e.g. just the
directory structure, no `AGENTS.md`) - honor that; the steps above are directory structure, no `AGENTS.md`) - honor that; the steps above are
the default full scaffold, not an all-or-nothing bundle. the default full scaffold, not an all-or-nothing bundle.
- **Clone fails and there's no local KB to fall back on** - stop and report
the raw git error. Don't hand-write an approximation of the scaffold from
memory; a KB whose `AGENTS.md` is a paraphrase is worse than no KB.
- **User asks to clone the template repo straight into the target** - point
out that this gives them the template's git history and files rather than
a fresh KB, and offer both: a plain `git clone` (if that's really what
they want) or the scratch-clone-then-init flow, which is the default.
- **Target already contains a `.git/`** - proceed with the scaffold, but
skip Step 11's `git init`/commit and say so; committing into someone
else's repo is theirs to decide.
- **Scratch clone path already exists** (a previous run died mid-way) -
remove it before cloning (`rm -rf`), and remove it again when done. Never
reuse a half-cloned scratch dir.
- **This repo's own `AGENTS.md` or template `wiki/` files have since - **This repo's own `AGENTS.md` or template `wiki/` files have since
drifted from each other** (e.g. one mentions a directory the other drifted from each other** (e.g. one mentions a directory the other
doesn't) - fix the drift in *this* repo first if noticed, then copy the doesn't) - fix the drift in *this* repo first if noticed, then copy the

View file

@ -17,6 +17,15 @@ Implemented as a skill (rather than living inline in
`CLAUDE.md`/`AGENTS.md`) so the full checklist only loads into context when `CLAUDE.md`/`AGENTS.md`) so the full checklist only loads into context when
actually invoked — on demand, or on a schedule if the user has set one up. actually invoked — on demand, or on a schedule if the user has set one up.
The **detection** half of this checklist is mechanical — a fixed set of
frontmatter, date, link, graph, and index/log rules over a tree that keeps
growing — so it runs as a read-only Python script rather than as per-page
model reasoning (Step 0 below). What the script cannot decide is what this
skill is actually for: which findings to auto-fix, which to leave for the
user, whether two pages genuinely supersede one another, and whether a
cluster of findings is a systemic pattern worth an error-book entry. Read
the script's report, then do that work.
This skill only ever writes within `wiki/` (including moves into This skill only ever writes within `wiki/` (including moves into
`wiki/archived/`) and, for any connector-backed `libs/<name>/` (one with a `wiki/archived/`) and, for any connector-backed `libs/<name>/` (one with a
`source.yaml` — see `CLAUDE.md`/`AGENTS.md` directory contract) where this user has `source.yaml` — see `CLAUDE.md`/`AGENTS.md` directory contract) where this user has
@ -57,59 +66,105 @@ are logged in that connector's own `log.md`, never in `wiki/log.md`. A
git-copy `libs/<name>/` (no `source.yaml`) and any `source.yaml`/ git-copy `libs/<name>/` (no `source.yaml`) and any `source.yaml`/
`source.local.yaml` are never touched by any of these checks. `source.local.yaml` are never touched by any of these checks.
### 0 — Run the checker script
From the repository root:
```bash
python3 "<skill-dir>/scripts/lint_report.py"
```
Resolve `<skill-dir>` to this skill's own directory. Flags: `--scope
wiki|libs|all` (default `all`) to limit which trees are checked; `--today
YYYY-MM-DD` to pin the date for reproducible runs; `--json` for a
machine-readable report. Exit code is `0` when nothing was found and `1`
when there are findings — read the report either way.
The script is strictly read-only: it writes nothing, moves nothing, and
runs no `git` commands. It covers the mechanical detection in checks 1, 2,
3, 4, 6, 7, 8, and 10 below — plus the structural half of check 5 (decision
records) — across `wiki/` and every connector-backed
`libs/<name>/` (reporting each one's `access:` level so you know where a
fix is even permitted). Check 9, the semantic half of check 5, and every
auto-fix-vs-report decision, are yours.
The numbered checks below stay as the reference for *what each finding
means and what to do about it* — you don't need to re-derive the detection
by hand. Do read the pages the script flags: a finding is a pointer to a
page that needs a decision, not the decision itself.
### 1 — Conformance check ### 1 — Conformance check
Verify every non-reserved `.md` file under `wiki/` (i.e. excluding The script flags every non-reserved `.md` file under `wiki/` (i.e.
`index.md` and `log.md`) has parseable YAML frontmatter with a non-empty excluding `index.md` and `log.md`) whose frontmatter is unparseable,
`type` field. Flag violations first, and treat flagged pages as absent, or missing a non-empty `type`. Treat flagged pages as unreliable
unreliable input for the checks below rather than guessing at their input for the checks below rather than guessing at their intended
intended type/content. type/content, and fix the frontmatter before acting on any later finding
about the same page.
### 2 — Freshness check ### 2 — Freshness check
Scan every page whose `last_updated` exceeds its `freshness_window_days`. The script reports every page whose `last_updated` exceeds its
Flag as stale; suggest the user confirm or update the content — don't `freshness_window_days`, and by how much. Suggest the user confirm or
silently rewrite stale content yourself. update the content — don't silently rewrite stale content yourself.
### 3 — Confidence decay ### 3 — Confidence decay
Reduce `confidence` on pages not reinforced by a new source since the last The script reports pages already below 0.3 `confidence` (and any
check. Pages that fall below 0.3 confidence get flagged for re-review. unparseable value). Decaying `confidence` on pages not reinforced by a new
source since the last check is a write and a judgment call, so it stays
yours — the script only surfaces where the floor has been crossed.
### 4 — Retention sweep ### 4 — Retention sweep
Move `retention: low` pages older than 2× their `freshness_window_days` The script reports `retention: low` pages older than 2x their
into `wiki/archived/`. Never delete — always move, and log the move (see `freshness_window_days` as archive candidates. Move them into
Rule B in `CLAUDE.md`/`AGENTS.md`) with a note explaining why. `wiki/archived/` — never delete, always move — and log each move (see
Rule B in `CLAUDE.md`/`AGENTS.md`) with a note explaining why. Check each
candidate against recent use before moving it (see Edge cases).
### 5 — Supersession detection ### 5 — Supersession and decision records
When two pages appear to cover the same entity, check for contradictions. The script checks the *structure* of every `type: decision` page: a status
If one is clearly newer/better corroborated, add `supersedes` / from the vocabulary, a `decided_on` date where the status implies one, a
`superseded_by` links between them. Preserve the older page rather than `decided_by` (`unknown` counts — an omitted field doesn't), `affects` targets
deleting it, but mark it stale. and supersession links that resolve, supersession set on **both** sides,
`status: superseded`/`reversed` matched by a `superseded_by`, unique
four-digit decision numbers, and any `review_on` date that has passed. Fix
those directly — they're mechanical. A `review_on` that has come due is not a
defect, though: report it so the user can decide whether to revisit, and never
change a decision's substance yourself (`ckb-decide` → decisions are
append-only).
The *semantic* half stays yours: when two pages appear to cover the same
entity or the same choice, check for contradictions. If one is clearly
newer/better corroborated, add `supersedes` / `superseded_by` links between
them. Preserve the older page rather than deleting it, but mark it stale. For
two decisions, that judgment is exactly what the script can't make — it can
tell you a link is one-sided, not that a link should exist.
### 6 — Orphan detection ### 6 — Orphan detection
Find pages with no inbound links (`[[wikilinks]]` or The script reports pages with no inbound links (`[[wikilinks]]` or
`[markdown](path.md)` references from elsewhere in the wiki). Either add `[markdown](path.md)` references from anywhere else in the tree). For each
backlinks from relevant pages where an obvious connection exists, or move one, either add backlinks from relevant pages where an obvious connection
the orphan to `wiki/archived/` with a log note if no natural backlink exists, or move the orphan to `wiki/archived/` with a log note if no
exists. natural backlink does — that choice is the judgment the script leaves you.
### 7 — Graph consistency ### 7 — Graph consistency
Verify every edge in `wiki/graph/edges.json` points to an existing entity The script reports every edge in `wiki/graph/edges.json` whose `from`/`to`
page. Remove or fix broken edges; note what was removed rather than does not resolve to an existing page (plus malformed edges and invalid
silently dropping entries. JSON). Remove or fix them; note what was removed rather than silently
dropping entries.
### 8 — Index/log consistency ### 8 — Index/log consistency
Verify every subdirectory under `wiki/` that contains pages has an The script reports subdirectories holding pages but no `index.md`, index
`index.md` listing all of them, and that no single change is recorded in files that don't list a page sitting next to them, and any change recorded
both a subdirectory `log.md` and the root `wiki/log.md` (per the in both a subdirectory `log.md` and the root `wiki/log.md` (per the
Recursive Index & Log Convention). Fix missing index entries and Recursive Index & Log Convention). Fix missing index entries and duplicate
duplicate log entries directly. log entries directly — these are the safest auto-fixes on the list.
### 9 — Error Book entry ### 9 — Error Book entry
@ -121,27 +176,28 @@ doesn't need an Error Book entry — this is for patterns, not incidents.
### 10 — External source config check ### 10 — External source config check
For each `libs/<name>/source.yaml`, verify it has a non-empty `connector` The script validates each `libs/<name>/source.yaml` (non-empty `connector`
and `location` — report only, this file is never edited by any skill. If and `location`; non-empty `store` and `location` inside any `index:` block;
an `index:` block is present, verify it has a non-empty `store` and a positive integer `refresh_interval_days`) and flags a `libs/<name>/` that
`location` too. If `refresh_interval_days` is present, verify it's a ambiguously holds both real content files and a `source.yaml`. All of this
positive integer. Also flag (report only) a `libs/<name>/` that is **report only**`source.yaml` is never edited by any skill, and the
ambiguously has both real content files and a `source.yaml` — that's a ambiguous-content case is a configuration conflict for the user to resolve,
configuration conflict for the user to resolve, not something to guess at. not something to guess at.
Report any connector-backed source whose generated index is overdue: It also reports any connector-backed source whose generated index is
newest `last_updated` in `libs/<name>/` older than its overdue — newest `last_updated` in `libs/<name>/` older than its
`refresh_interval_days` (default 30). Report it the same way whether or `refresh_interval_days` (default 30) — with how overdue it is, since a
not this user has write access — a read-only user can't fix it, but source two days past a 7-day interval is a different situation from one six
knowing which source to chase the admin about is the actionable part. Name months past a 30-day one. Relay that the same way whether or not this user
how overdue it is rather than just "stale," since a source two days past a has write access: a read-only user can't fix it, but knowing which source to
7-day interval is a different situation from one six months past a 30-day chase the admin about is the actionable part. Never re-index here; that's
one. Never re-index here; that's `ckb-index-external`'s job, and suggesting `ckb-index-external`'s job, and suggesting it is as far as this check goes.
it is as far as this check goes.
Don't flag a missing `libs/<name>/source.local.yaml` as an issue — its Don't flag a missing `libs/<name>/source.local.yaml` as an issue — its
absence is the correct, read-only default (see `ckb-index-external`), not absence is the correct, read-only default (see `ckb-index-external`), not
a gap to report or fix. a gap to report or fix. The script follows the same rule: it reads that
file only to label each connector `access: read-only` / `access: write` in
its report.
### Auto-fix vs. report ### Auto-fix vs. report
@ -172,11 +228,14 @@ fix or flag), skip this reminder — there's nothing to review or sync.
## Edge cases ## Edge cases
- **Wiki is empty or near-empty** — report that there's little to lint - **Wiki is empty or near-empty** — report that there's little to lint
yet; don't fabricate findings to look thorough. yet; don't fabricate findings to look thorough. A `total findings: 0`
report is a valid outcome; say so plainly rather than hunting for
something to say.
- **A page's frontmatter is unparseable** (not just missing `type`, but - **A page's frontmatter is unparseable** (not just missing `type`, but
invalid YAML) — flag it prominently in the conformance check and skip invalid YAML) — the script reports it under conformance and skips it in
it in every later numbered check rather than letting a parse error the later checks rather than reasoning from a half-parsed page. Surface
crash or silently mis-handle downstream logic. it prominently and fix the frontmatter first; a page can't be judged
stale, orphaned, or archivable until it parses.
- **Retention sweep would archive a page that's clearly still in active - **Retention sweep would archive a page that's clearly still in active
use** (e.g. linked from a very recent `workload/` entry) — flag it for use** (e.g. linked from a very recent `workload/` entry) — flag it for
the user to confirm rather than auto-archiving; recency of use can the user to confirm rather than auto-archiving; recency of use can
@ -184,9 +243,12 @@ fix or flag), skip this reminder — there's nothing to review or sync.
- **Supersession is ambiguous** (two pages disagree and neither is - **Supersession is ambiguous** (two pages disagree and neither is
clearly newer/better corroborated) — report the conflict rather than clearly newer/better corroborated) — report the conflict rather than
guessing which one wins. guessing which one wins.
- **Repeated run with nothing changed since the last lint** — should - **Repeated run with nothing changed since the last lint** — produces the
produce essentially the same clean report each time; don't invent same report each time (pass `--today` to pin the date if you need a
variation just to seem active. byte-identical one); don't invent variation just to seem active.
- **The script errors out or isn't runnable** (no Python 3, unreadable
tree) — say so and fall back to working the numbered checks by hand
rather than reporting a clean bill of health you didn't actually verify.
--- ---

View file

@ -0,0 +1,607 @@
#!/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.
"""Deterministic half of the ckb-lint health check: detect, never fix.
Usage:
python3 lint_report.py [--scope wiki|libs|all] [--today YYYY-MM-DD] [--json]
Run from the repository root (the directory containing wiki/ and libs/).
This script is strictly READ-ONLY. It writes no files, moves nothing, and runs
no git commands it prints findings for the agent to act on. The mechanical
checks (conformance, freshness, retention/decay candidates, orphans, graph
consistency, index/log consistency, source.yaml validity) live here; the
judgment calls the ckb-lint skill owns (whether two pages genuinely supersede
one another, ambiguous orphans, error-book entries, auto-fix vs. report) stay
with the model. Decision records get their own structural checks here status
vocabulary, required dates, two-sided supersession links since those are
mechanical; deciding that a new decision *replaces* an old one is not.
Exit code is 0 when nothing was found and 1 when there are findings.
"""
import argparse
import json
import re
import sys
from datetime import date, datetime
from pathlib import Path
REPO_ROOT = Path.cwd()
WIKI = REPO_ROOT / "wiki"
LIBS = REPO_ROOT / "libs"
RESERVED = {"index.md", "log.md"}
DEFAULT_REFRESH_DAYS = 30
DECISION_STATUSES = {"proposed", "accepted", "rejected", "superseded", "reversed"}
# --------------------------------------------------------------------------
# tiny YAML readers (stdlib only — this template ships without dependencies)
# --------------------------------------------------------------------------
def parse_frontmatter(text):
"""Return (dict, body, error). `error` is a string when the frontmatter is
present but unparseable, else None. Flat `key: value` pairs only."""
if not text.startswith("---\n"):
return {}, text, None
end = text.find("\n---\n", 4)
if end == -1:
return {}, text, "frontmatter opened with `---` but never closed"
raw, body = text[4:end], text[end + 5 :]
fm = {}
for lineno, line in enumerate(raw.split("\n"), start=2):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if line[:1].isspace():
return {}, body, f"line {lineno}: nested/indented YAML is not supported in page frontmatter"
if ":" not in stripped:
return {}, body, f"line {lineno}: not a `key: value` pair: {stripped!r}"
key, _, value = stripped.partition(":")
fm[key.strip()] = unquote(value.strip())
return fm, body, None
def unquote(value):
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
def parse_simple_yaml(text):
"""Two-level `key: value` / `key:` + indented block YAML, enough for
source.yaml and source.local.yaml. Returns a dict; nested blocks become
nested dicts."""
out, current = {}, None
for line in text.split("\n"):
if not line.strip() or line.strip().startswith("#"):
continue
indented = line[:1].isspace()
stripped = line.strip()
if ":" not in stripped:
continue
key, _, value = stripped.partition(":")
key, value = key.strip(), unquote(value.split(" #", 1)[0].strip())
if indented:
if isinstance(current, dict):
current[key] = value
continue
if value == "":
current = {}
out[key] = current
else:
out[key] = value
current = None
# `key:` with nothing indented under it is an empty scalar, not a block.
return {k: ("" if v == {} else v) for k, v in out.items()}
def as_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def as_int(value):
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def as_date(value):
try:
return datetime.strptime(str(value).strip(), "%Y-%m-%d").date()
except (TypeError, ValueError):
return None
# --------------------------------------------------------------------------
# tree model
# --------------------------------------------------------------------------
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
class Doc:
def __init__(self, root, path):
self.path = path
self.rel = path.relative_to(root)
text = path.read_text(encoding="utf-8", errors="replace")
self.fm, self.body, self.fm_error = parse_frontmatter(text)
self.reserved = path.name in RESERVED
def links(self):
"""Every intra-tree link target as a path relative to the tree root."""
out = set()
for target in LINK_RE.findall(self.body):
target = target.split("#", 1)[0].strip()
if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target):
continue
if target.startswith("/wiki/"):
out.add(target[len("/wiki/") :])
elif target.startswith("/"):
out.add(target.lstrip("/"))
else:
try:
resolved = (self.rel.parent / target).as_posix()
except ValueError:
continue
parts = []
for part in resolved.split("/"):
if part == "..":
if parts:
parts.pop()
elif part not in (".", ""):
parts.append(part)
out.add("/".join(parts))
return out
def wikilinks(self):
return {w.split("|", 1)[-1].strip() for w in WIKILINK_RE.findall(self.body)}
def read_tree(root):
docs = []
for path in sorted(root.rglob("*.md")):
if any(part.startswith(".") for part in path.relative_to(root).parts):
continue
docs.append(Doc(root, path))
return docs
# --------------------------------------------------------------------------
# checks (each returns a list of finding strings)
# --------------------------------------------------------------------------
def check_conformance(docs):
findings = []
for doc in docs:
if doc.reserved:
continue
if doc.fm_error:
findings.append(f"{doc.rel}: unparseable frontmatter — {doc.fm_error}")
elif not doc.fm:
findings.append(f"{doc.rel}: no frontmatter")
elif not doc.fm.get("type", "").strip():
findings.append(f"{doc.rel}: missing or empty `type`")
return findings
def check_freshness(docs, today):
findings = []
for doc in docs:
if doc.reserved or doc.fm_error:
continue
window = as_int(doc.fm.get("freshness_window_days"))
updated = as_date(doc.fm.get("last_updated"))
if window is None or updated is None:
if doc.fm and doc.fm.get("last_updated") and updated is None:
findings.append(f"{doc.rel}: `last_updated` is not a YYYY-MM-DD date: {doc.fm['last_updated']!r}")
continue
age = (today - updated).days
if age > window:
findings.append(f"{doc.rel}: stale — {age}d since last_updated, window is {window}d ({age - window}d over)")
return findings
def check_confidence(docs):
findings = []
for doc in docs:
if doc.reserved or doc.fm_error or "confidence" not in doc.fm:
continue
value = as_float(doc.fm["confidence"])
if value is None:
findings.append(f"{doc.rel}: `confidence` is not a number: {doc.fm['confidence']!r}")
elif value < 0.3:
findings.append(f"{doc.rel}: confidence {value} is below 0.3 — flag for re-review")
return findings
def check_retention(docs, today):
findings = []
for doc in docs:
if doc.reserved or doc.fm_error:
continue
if doc.fm.get("retention", "").strip().lower() != "low":
continue
window = as_int(doc.fm.get("freshness_window_days"))
updated = as_date(doc.fm.get("last_updated"))
if window is None or updated is None:
continue
age = (today - updated).days
if age > 2 * window:
findings.append(
f"{doc.rel}: archive candidate — retention: low, {age}d old, 2x window is {2 * window}d"
)
return findings
def check_decisions(docs, root, today):
"""Decision-record specific rules (see the ckb-decide skill)."""
findings = []
decisions = [d for d in docs if not d.reserved and not d.fm_error
and d.fm.get("type", "").strip().lower() == "decision"]
by_path = {d.rel.as_posix(): d for d in decisions}
seen_numbers = {}
for doc in sorted(decisions, key=lambda d: d.rel.as_posix()):
rel = doc.rel.as_posix()
status = doc.fm.get("status", "").strip().lower()
if not status:
findings.append(f"{rel}: decision has no `status`")
elif status not in DECISION_STATUSES:
findings.append(
f"{rel}: `status: {status}` is not one of {', '.join(sorted(DECISION_STATUSES))}"
)
decided_on = as_date(doc.fm.get("decided_on"))
if status in ("accepted", "rejected", "reversed"):
if not doc.fm.get("decided_on"):
findings.append(f"{rel}: `status: {status}` but no `decided_on` date")
elif decided_on is None:
findings.append(f"{rel}: `decided_on` is not a YYYY-MM-DD date: {doc.fm['decided_on']!r}")
if decided_on and decided_on > today:
findings.append(f"{rel}: `decided_on` is in the future: {decided_on.isoformat()}")
if not doc.fm.get("decided_by", "").strip():
findings.append(f"{rel}: no `decided_by` — record `unknown` rather than omitting it")
review = doc.fm.get("review_on")
if review:
review_date = as_date(review)
if review_date is None:
findings.append(f"{rel}: `review_on` is not a YYYY-MM-DD date: {review!r}")
elif review_date <= today:
findings.append(
f"{rel}: due for review — `review_on: {review_date.isoformat()}` "
f"passed {(today - review_date).days}d ago"
)
# Supersession links must resolve and must be reciprocal.
for field, mirror in (("supersedes", "superseded_by"), ("superseded_by", "supersedes")):
target = doc.fm.get(field, "").strip()
if not target:
continue
key = target[len("/wiki/") :] if target.startswith("/wiki/") else target.lstrip("/")
other = by_path.get(key)
if other is None:
if not (root / key).is_file():
findings.append(f"{rel}: `{field}: {target}` does not resolve to a page")
continue
back = other.fm.get(mirror, "").strip()
back_key = back[len("/wiki/") :] if back.startswith("/wiki/") else back.lstrip("/")
if back_key != rel:
findings.append(
f"{rel}: `{field}` points at {key}, but that page's `{mirror}` "
f"does not point back ({back or 'unset'}) — supersession must be two-sided"
)
if status in ("superseded", "reversed") and not doc.fm.get("superseded_by", "").strip():
findings.append(f"{rel}: `status: {status}` but no `superseded_by` naming what replaced it")
if doc.fm.get("superseded_by", "").strip() and status not in ("superseded", "reversed"):
findings.append(
f"{rel}: has `superseded_by` but `status: {status or 'unset'}` — "
"expected `superseded` or `reversed`"
)
for target in [t.strip() for t in doc.fm.get("affects", "").split(",") if t.strip()]:
key = target[len("/wiki/") :] if target.startswith("/wiki/") else target.lstrip("/")
if not (root / key).is_file():
findings.append(f"{rel}: `affects` entry does not resolve to a page: {target}")
m = re.match(r"^(\d{4})-", doc.rel.name)
if m:
seen_numbers.setdefault(m.group(1), []).append(rel)
else:
findings.append(f"{rel}: decision filename does not start with a four-digit number")
for number, paths in sorted(seen_numbers.items()):
if len(paths) > 1:
findings.append(f"decision number {number} used by more than one page: {', '.join(sorted(paths))}")
return findings
def check_orphans(docs):
inbound = set()
titles = {}
for doc in docs:
rel = doc.rel.as_posix()
titles[rel] = rel
stem = doc.rel.with_suffix("").as_posix()
titles[stem] = rel
for doc in docs:
for target in doc.links():
if target in titles:
inbound.add(titles[target])
for wl in doc.wikilinks():
key = wl[:-3] if wl.endswith(".md") else wl
if key in titles:
inbound.add(titles[key])
findings = []
for doc in docs:
rel = doc.rel.as_posix()
if doc.reserved or rel in inbound:
continue
findings.append(f"{rel}: no inbound links from anywhere in the tree")
return findings
def check_graph(root, docs):
findings = []
edges_path = root / "graph" / "edges.json"
if not edges_path.is_file():
return findings
try:
data = json.loads(edges_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return [f"graph/edges.json: invalid JSON — {exc}"]
edges = data.get("edges", data) if isinstance(data, dict) else data
if not isinstance(edges, list):
return ["graph/edges.json: `edges` is not a list"]
known = {doc.rel.as_posix() for doc in docs}
known |= {doc.rel.with_suffix("").as_posix() for doc in docs}
for i, edge in enumerate(edges):
if not isinstance(edge, dict):
findings.append(f"graph/edges.json: edge {i} is not an object")
continue
for side in ("from", "to"):
value = str(edge.get(side, "")).strip()
if not value:
findings.append(f"graph/edges.json: edge {i} has no `{side}`")
continue
candidates = {
value,
value.removesuffix(".md"),
f"entities/{value}",
f"entities/{value}".removesuffix(".md"),
}
if not candidates & known:
findings.append(f"graph/edges.json: edge {i} `{side}: {value}` does not resolve to a page")
return findings
def check_index_and_logs(root, docs):
findings = []
by_dir = {}
for doc in docs:
by_dir.setdefault(doc.rel.parent, []).append(doc)
for directory, entries in sorted(by_dir.items()):
pages = [d for d in entries if not d.reserved]
index = next((d for d in entries if d.path.name == "index.md"), None)
if not pages:
continue
if index is None:
findings.append(f"{(directory / 'index.md').as_posix()}: missing — directory holds {len(pages)} page(s)")
continue
listed = {Path(t).name for t in index.links()} | {
(w if w.endswith(".md") else w + ".md").split("/")[-1] for w in index.wikilinks()
}
for page in sorted(pages, key=lambda d: d.rel.as_posix()):
if page.path.name not in listed:
findings.append(f"{index.rel.as_posix()}: does not list {page.rel.as_posix()}")
# A change must have exactly one home log (Recursive Index & Log Convention).
logs = [d for d in docs if d.path.name == "log.md"]
root_log = next((d for d in logs if d.rel == Path("log.md")), None)
if root_log is not None and len(logs) > 1:
root_entries = log_entry_keys(root_log.body)
for sub in logs:
if sub is root_log:
continue
for key in sorted(log_entry_keys(sub.body) & root_entries):
findings.append(
f"log.md and {sub.rel.as_posix()}: same change recorded in both — {key}"
)
return findings
LOG_HEADER_RE = re.compile(r"^##\s*\[?(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]?")
def log_entry_keys(body):
"""Timestamp + affected-files pairs, used to spot a change logged twice."""
keys, stamp = set(), None
for line in body.split("\n"):
m = LOG_HEADER_RE.match(line.strip())
if m:
stamp = m.group(1)
continue
f = re.match(r"^-\s*\*\*File Affected:?\*\*:?\s*(.+)$", line.strip())
if f and stamp:
keys.add(f"{stamp} {f.group(1).strip()}")
return keys
def check_sources(today):
"""source.yaml validity plus overdue-index reporting for connector libs."""
findings, connectors = [], []
if not LIBS.is_dir():
return findings, connectors
for lib in sorted(p for p in LIBS.iterdir() if p.is_dir()):
source = lib / "source.yaml"
if not source.is_file():
continue # git-copy lib — never touched by lint
connectors.append(lib)
cfg = parse_simple_yaml(source.read_text(encoding="utf-8"))
name = lib.name
for key in ("connector", "location"):
if not str(cfg.get(key, "")).strip():
findings.append(f"libs/{name}/source.yaml: `{key}` is missing or empty")
index_block = cfg.get("index")
if isinstance(index_block, dict):
for key in ("store", "location"):
if not str(index_block.get(key, "")).strip():
findings.append(f"libs/{name}/source.yaml: `index.{key}` is missing or empty")
refresh = cfg.get("refresh_interval_days")
interval = DEFAULT_REFRESH_DAYS
if refresh is not None and not isinstance(refresh, dict):
parsed = as_int(refresh)
if parsed is None or parsed <= 0:
findings.append(f"libs/{name}/source.yaml: `refresh_interval_days` is not a positive integer: {refresh!r}")
else:
interval = parsed
generated = {"index.md", "log.md", "entities", "graph", "source.yaml", "source.local.yaml"}
stray = [
p.name
for p in sorted(lib.iterdir())
if p.name not in generated and not p.name.startswith(".")
]
if stray:
findings.append(
f"libs/{name}/: has both a source.yaml and non-index content ({', '.join(stray)}) — "
"ambiguous configuration for the user to resolve"
)
newest = None
for md in lib.rglob("*.md"):
fm, _, err = parse_frontmatter(md.read_text(encoding="utf-8", errors="replace"))
if err:
continue
updated = as_date(fm.get("last_updated"))
if updated and (newest is None or updated > newest):
newest = updated
if newest is None:
findings.append(f"libs/{name}/: no generated index yet — run 'index external sources'")
else:
age = (today - newest).days
if age > interval:
findings.append(
f"libs/{name}/: index is {age}d old against a {interval}d refresh interval "
f"({age - interval}d overdue) — suggest 'index external sources'"
)
return findings, connectors
# --------------------------------------------------------------------------
# driver
# --------------------------------------------------------------------------
CHECKS = [
("1 conformance", "conformance"),
("2 freshness", "freshness"),
("3 confidence", "confidence"),
("4 retention", "retention"),
("5 decisions", "decisions"),
("6 orphans", "orphans"),
("7 graph", "graph"),
("8 index/log", "index_log"),
]
def run_tree(root, today, label):
docs = read_tree(root)
return {
"label": label,
"documents": len(docs),
"conformance": check_conformance(docs),
"freshness": check_freshness(docs, today),
"confidence": check_confidence(docs),
"retention": check_retention(docs, today),
"decisions": check_decisions(docs, root, today),
"orphans": check_orphans(docs),
"graph": check_graph(root, docs),
"index_log": check_index_and_logs(root, docs),
}
def main():
parser = argparse.ArgumentParser(description="Read-only mechanical checks for ckb-lint.")
parser.add_argument("--scope", choices=("wiki", "libs", "all"), default="all")
parser.add_argument("--today", help="override today's date (YYYY-MM-DD) for reproducible runs")
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of text")
args = parser.parse_args()
today = as_date(args.today) if args.today else date.today()
if today is None:
print(f"error: --today is not a YYYY-MM-DD date: {args.today!r}", file=sys.stderr)
return 2
trees, source_findings = [], []
if args.scope in ("wiki", "all"):
if not WIKI.is_dir():
print(f"error: no wiki/ directory under {REPO_ROOT} — run from the repository root", file=sys.stderr)
return 2
trees.append(run_tree(WIKI, today, "wiki/"))
if args.scope in ("libs", "all"):
source_findings, connectors = check_sources(today)
for lib in connectors:
access = "read-only"
local = lib / "source.local.yaml"
if local.is_file():
cfg = parse_simple_yaml(local.read_text(encoding="utf-8"))
if str(cfg.get("access", "")).strip().lower() == "write":
access = "write"
tree = run_tree(lib, today, f"libs/{lib.name}/ (access: {access})")
tree["access"] = access
trees.append(tree)
total = sum(len(t[key]) for t in trees for _, key in CHECKS) + len(source_findings)
if args.json:
print(json.dumps({"today": today.isoformat(), "trees": trees, "sources": source_findings, "findings": total}, indent=2))
return 1 if total else 0
for tree in trees:
print(f"=== {tree['label']}{tree['documents']} markdown file(s)")
for title, key in CHECKS:
items = tree[key]
if not items:
continue
print(f" [{title}] {len(items)} finding(s)")
for item in items:
print(f" - {item}")
if not any(tree[key] for _, key in CHECKS):
print(" clean")
if args.scope in ("libs", "all"):
print("=== external sources (libs/*/source.yaml)")
if source_findings:
for item in source_findings:
print(f" - {item}")
else:
print(" clean")
print(f"total findings: {total}")
print("checks NOT covered here (model's job): whether two pages genuinely supersede "
"each other, 9 error-book, auto-fix vs. report")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -67,7 +67,14 @@ Same cascade order as CLAUDE.md/AGENTS.md, first match wins:
boundary. A project scope narrows the first pass only; it never hides boundary. A project scope narrows the first pass only; it never hides
the rest of the cascade. the rest of the cascade.
3. `wiki/entities/index.md` — match against entity titles/`tldr`. 3. `wiki/entities/index.md` — match against entity titles/`tldr`.
4. If nothing local matches: each `linked/<name>/` index (alphabetical), 4. `wiki/decisions/index.md` — for any "why is it like this", "who decided",
"when did we choose", or "is that still current" question. If decisions
*are* the subject of the question, hand off to `ckb-decide`, which owns
the decisions-scoped path including supersession chains; pull decision
pages in here as one source among many when they're only part of a wider
answer. Either way, a superseded decision is history, not noise — say so
rather than silently answering from the current one.
5. If nothing local matches: each `linked/<name>/` index (alphabetical),
then each connector-backed `libs/<name>/entities/index.md` — for a then each connector-backed `libs/<name>/entities/index.md` — for a
connector-backed lib this means its *generated* index (both the connector-backed lib this means its *generated* index (both the
Documents and the Entities & Processes sections `ckb-index-external` Documents and the Entities & Processes sections `ckb-index-external`

2
.gitignore vendored
View file

@ -16,3 +16,5 @@ outputs/starlight
outputs/okf outputs/okf
outputs/teaching outputs/teaching
.env .env
__pycache__/
*.pyc

View file

@ -31,6 +31,7 @@ Maintain this root layout:
│ ├── error-book.md │ ├── error-book.md
│ ├── query-gaps.md │ ├── query-gaps.md
│ ├── projects/ │ ├── projects/
│ ├── decisions/ # Numbered, append-only decision records
│ ├── entities/ │ ├── entities/
│ └── graph/ │ └── graph/
└── workload/ # Session summaries and decisions └── workload/ # Session summaries and decisions
@ -86,7 +87,14 @@ retention: high|medium|low
--- ---
``` ```
`wiki/index.md` alone also carries `kb_schema_version`, currently `"1.3"`. Pages with `type: decision` live in `wiki/decisions/` as `NNNN-slug.md` and
add `status` (`proposed`/`accepted`/`rejected`/`superseded`/`reversed`),
`decided_on`, `decided_by`, and optionally `affects` and `review_on`. They are
append-only: never rewrite a decision's substance to match a later change of
mind — record a new decision that supersedes it, and set both
`supersedes` and `superseded_by`. `ckb-decide` owns the format.
`wiki/index.md` alone also carries `kb_schema_version`, currently `"1.4"`.
Detailed schema migration and version-bump policy belongs in Detailed schema migration and version-bump policy belongs in
`ckb-upgrade`. `ckb-upgrade`.
@ -100,14 +108,15 @@ file.
|---|---| |---|---|
| Answer or research a KB question | `ckb-retrieve` | | Answer or research a KB question | `ckb-retrieve` |
| Ingest raw material into `wiki/` | `ckb-ingest` | | Ingest raw material into `wiki/` | `ckb-ingest` |
| Record a decision, or answer what/why/who/when was decided | `ckb-decide` |
| Index connector-backed `libs/` sources | `ckb-index-external` | | Index connector-backed `libs/` sources | `ckb-index-external` |
| Health-check or repair wiki/index structure | `ckb-lint` | | Health-check or repair wiki/index structure | `ckb-lint` |
| Sync this repo with `origin` | `ckb-sync-changes` | | Sync this repo with `origin` | `ckb-sync-changes` |
| Upgrade template or wiki schema | `ckb-upgrade` | | Upgrade template or wiki schema | `ckb-upgrade` |
| Bootstrap a new empty KB | `ckb-init` | | Bootstrap a new empty KB (from local files or a fresh clone of the template repo) | `ckb-init` |
| Export OKF or Starlight artifacts | `ckb-export-okf`, `ckb-export-starlight` | | Export OKF or Starlight artifacts | `ckb-export-okf`, `ckb-export-starlight` |
| Generate a project overview | `ckb-project-summary` | | Generate a project overview | `ckb-project-summary` |
| Teach, quiz, or onboard from the wiki | `ckb-teach-me`, `cbk-quiz`, `ckb-onboard-me` | | Teach, quiz, or onboard from the wiki | `ckb-teach-me`, `ckb-quiz`, `ckb-onboard-me` |
Short routing rules: Short routing rules:
@ -116,6 +125,10 @@ Short routing rules:
ownership lookups, evidence packets, source verification, answer ownership lookups, evidence packets, source verification, answer
caveats, and query-gap capture. caveats, and query-gap capture.
- For "Ingest", "Sync the wiki", or "Update the wiki", use `ckb-ingest`. - For "Ingest", "Sync the wiki", or "Update the wiki", use `ckb-ingest`.
- For "record a decision", "we decided ...", or a question whose subject is a
decision (what/why/who/when, what superseded it, what is still open), use
`ckb-decide`. For a wider question that merely touches decisions, stay in
`ckb-retrieve`.
- For "Index external sources", "index libs", or "refresh the external - For "Index external sources", "index libs", or "refresh the external
index", use `ckb-index-external`. index", use `ckb-index-external`.
- For "Lint" or "health-check the wiki", use `ckb-lint`. - For "Lint" or "health-check the wiki", use `ckb-lint`.

View file

@ -48,6 +48,14 @@ the target folder. It never copies this project's actual content (no
entities, no graph data, no notes). You get a fresh, empty KB, ready for its entities, no graph data, no notes). You get a fresh, empty KB, ready for its
first `raw/inbox/` drop. See `.agents/skills/ckb-init/SKILL.md`. first `raw/inbox/` drop. See `.agents/skills/ckb-init/SKILL.md`.
The template can come from two places: this repo's own files, or a fresh
shallow clone of the canonical template repo (or any fork/mirror URL you
name) pulled into a scratch folder. Say "pull the latest template and set
up a KB in \<folder\>" — or run it from outside any KB at all — and the
agent clones first, then builds the scaffold from that. The clone is
scratch only: the new KB gets its own git history (the agent asks before
running `git init`), not the template's.
If the target folder already looks like a KB (it has a `wiki/` or If the target folder already looks like a KB (it has a `wiki/` or
`AGENTS.md` already), the agent will stop and ask before touching anything `AGENTS.md` already), the agent will stop and ask before touching anything
— it won't silently overwrite an existing knowledge base. — it won't silently overwrite an existing knowledge base.
@ -194,7 +202,45 @@ Durable misses can also be tracked in `wiki/query-gaps.md`. A good gap
entry is tiny: the question, where the agent looked, and the smallest entry is tiny: the question, where the agent looked, and the smallest
source or page that would make the answer available next time. source or page that would make the answer available next time.
### D. Create a local project scope ### D. Record a decision
When a call gets made — a technology choice, a process change, a policy —
say:
> "Record a decision: we're moving billing to Postgres. Alice and Bob
> decided it today, because the reporting queries were killing MySQL."
The agent writes a numbered record under `wiki/decisions/` with the decision,
who decided, when, the rationale, the alternatives, and what it affects. If
it replaces an earlier decision, it links the two in both directions and
marks the old one superseded — without touching the old page's reasoning.
Anything you leave out, it asks for in one round; if you're mid-flow, say so
and it records what you gave it and tells you which fields it left open.
Then ask about them however you like:
> "What did we decide about the billing database?"
> "Why do we use Postgres?"
> "Who decided that, and when?"
> "Which decisions are still just proposed?"
> "What superseded decision 3?"
The answer always comes with who and when attached, and says plainly when a
decision is proposed rather than accepted, or has since been superseded —
so you don't act on something that isn't in force. Implemented by the
`ckb-decide` skill.
Two things worth knowing:
- **Decisions are append-only.** "Actually, we changed our mind" creates a
*new* decision that supersedes the old one; it never edits the old one's
reasoning. That's deliberate — the history is the point. Genuine
transcription errors ("I said Alice, it was Anna") do get fixed in place.
- **A proposal is not a decision.** If it wasn't actually settled, it's
recorded as `proposed` with no decision date, and shows up when you ask
what's still open.
### E. Create a local project scope
When a topic, client, system, or initiative comes up often, ask: When a topic, client, system, or initiative comes up often, ask:
@ -229,8 +275,12 @@ This runs a health check across the whole wiki:
that source, since it tells you who to chase that source, since it tells you who to chase
- recurring systemic issues get written into `wiki/error-book.md` - recurring systemic issues get written into `wiki/error-book.md`
It fixes what it safely can on its own, and reports the rest for you to The detection half runs as a read-only Python script
decide. Like Ingest, it finishes by reminding you to review and sync. (`scripts/lint_report.py`), so the same wiki always produces the same
findings list — the agent reads that report and then does the parts that
need judgment (supersession, ambiguous orphans, error-book entries, and
deciding what to fix versus what to hand back to you). It fixes what it
safely can on its own, and reports the rest for you to decide. Like Ingest, it finishes by reminding you to review and sync.
Implemented by the `ckb-lint` skill — Implemented by the `ckb-lint` skill —
`.agents/skills/ckb-lint/SKILL.md`. `.agents/skills/ckb-lint/SKILL.md`.
@ -429,7 +479,7 @@ smallest source to drop into `raw/inbox/`.
You'll be asked how many questions and what format (open / multiple You'll be asked how many questions and what format (open / multiple
choice), then run through them one at a time with immediate feedback and a choice), then run through them one at a time with immediate feedback and a
running score. Nothing is saved afterward — it's a one-off check. running score. Nothing is saved afterward — it's a one-off check.
`.agents/skills/cbk-quiz/SKILL.md`. `.agents/skills/ckb-quiz/SKILL.md`.
**A proper course, spread over time** — say: **A proper course, spread over time** — say:
@ -605,6 +655,7 @@ graph stay in sync with what you changed.
| `libs/<name>/source.yaml` (connector) | **You, only** | Declares the connector, location, optionally how often it should be refreshed (`refresh_interval_days:`), and optionally where a shared/pre-built index lives (`index:`). The agent reads it but never writes it — same as anything else upstream. | | `libs/<name>/source.yaml` (connector) | **You, only** | Declares the connector, location, optionally how often it should be refreshed (`refresh_interval_days:`), and optionally where a shared/pre-built index lives (`index:`). The agent reads it but never writes it — same as anything else upstream. |
| `libs/<name>/source.local.yaml` (connector) | **You** (or the agent, only when you explicitly ask to become/stop being that source's admin) | Per-person, per-machine `access: write`/`read` setting — never committed, never seen by anyone else. Absent = read-only, the default. | | `libs/<name>/source.local.yaml` (connector) | **You** (or the agent, only when you explicitly ask to become/stop being that source's admin) | Per-person, per-machine `access: write`/`read` setting — never committed, never seen by anyone else. Absent = read-only, the default. |
| `libs/<name>/{index.md,entities/,graph/,log.md}` (connector) | Agent-generated, **you can freely edit** | The agent's own index of that one connector's source, built/refreshed by "Index external sources" — but only if you have `access: write` locally; read-only users just get a fetched copy. Structurally the same deal as the main `wiki/` row below — feel free to correct an entry by hand, then run "Lint" (it now also checks connector-backed indexes, respecting the same read/write split). Scoped entirely to that connector; never blended into `wiki/`. | | `libs/<name>/{index.md,entities/,graph/,log.md}` (connector) | Agent-generated, **you can freely edit** | The agent's own index of that one connector's source, built/refreshed by "Index external sources" — but only if you have `access: write` locally; read-only users just get a fetched copy. Structurally the same deal as the main `wiki/` row below — feel free to correct an entry by hand, then run "Lint" (it now also checks connector-backed indexes, respecting the same read/write split). Scoped entirely to that connector; never blended into `wiki/`. |
| `wiki/decisions/` | Agent-generated, **edit with care** | Same as the rest of `wiki/` mechanically, but these are append-only by convention: correct a typo or a misattributed name freely, and don't rewrite a decision's context or rationale to match a later view — record a superseding decision instead, so the history survives. |
| `wiki/` (pages, `index.md`, `overview.md`, `log.md`, `error-book.md`, `entities/`, `graph/`) | Agent-generated, **you can freely edit** | This is the one place the agent both writes and expects you might too. Feel free to correct a page by hand — just keep the frontmatter fields intact (or update `last_updated`), and run Lint afterward if you touched something the index/graph/log reference. | | `wiki/` (pages, `index.md`, `overview.md`, `log.md`, `error-book.md`, `entities/`, `graph/`) | Agent-generated, **you can freely edit** | This is the one place the agent both writes and expects you might too. Feel free to correct a page by hand — just keep the frontmatter fields intact (or update `last_updated`), and run Lint afterward if you touched something the index/graph/log reference. |
| `outputs/okf/`, `outputs/starlight/` | Agent, **fully regenerated** | Don't hand-edit — these are gitignored build artifacts, silently overwritten the next time you export. If something's wrong, fix the wiki page it came from and re-export. | | `outputs/okf/`, `outputs/starlight/` | Agent, **fully regenerated** | Don't hand-edit — these are gitignored build artifacts, silently overwritten the next time you export. If something's wrong, fix the wiki page it came from and re-export. |
| `outputs/teaching/<topic>/` | Agent, semi-persistent state | `plan.md`/`progress.md` the teaching skill reads and writes across sessions. You can look at them any time; hand-editing is possible but may confuse "what's next" tracking — safer to tell the agent what you want changed and let it update the files. | | `outputs/teaching/<topic>/` | Agent, semi-persistent state | `plan.md`/`progress.md` the teaching skill reads and writes across sessions. You can look at them any time; hand-editing is possible but may confuse "what's next" tracking — safer to tell the agent what you want changed and let it update the files. |
@ -621,10 +672,13 @@ graph stay in sync with what you changed.
| Say... | What happens | Skill | | Say... | What happens | Skill |
|---|---|---| |---|---|---|
| "Set up a new wiki like this one in \<folder\>" | Bootstraps a fresh, empty KB with this schema | `ckb-init` | | "Set up a new wiki like this one in \<folder\>" | Bootstraps a fresh, empty KB with this schema | `ckb-init` |
| "Pull the ckb repo into \<folder\> and set up the wiki" | Clones the template repo to a scratch dir, then bootstraps an empty KB from it | `ckb-init` |
| "Ingest" / "Sync the wiki" / "Update the wiki" | Processes `raw/inbox/` into structured `wiki/` pages | `ckb-ingest` | | "Ingest" / "Sync the wiki" / "Update the wiki" | Processes `raw/inbox/` into structured `wiki/` pages | `ckb-ingest` |
| "Record a decision: ..." / "we decided ..." | Writes a numbered decision record under `wiki/decisions/` | `ckb-decide` |
| "What did we decide about X" / "who decided X" / "what's still open" | Answers from the decision records, with who/when/status attached | `ckb-decide` |
| "Lint" | Health-checks the wiki, auto-fixes what it safely can | `ckb-lint` | | "Lint" | Health-checks the wiki, auto-fixes what it safely can | `ckb-lint` |
| "Sync changes" / "Sync with git" | Commits, pulls, resolves conflicts, pushes to `origin` | `ckb-sync-changes` | | "Sync changes" / "Sync with git" | Commits, pulls, resolves conflicts, pushes to `origin` | `ckb-sync-changes` |
| "Quiz me on X" | One-off scored knowledge test | `cbk-quiz` | | "Quiz me on X" | One-off scored knowledge test | `ckb-quiz` |
| "Teach me the wiki" / "Teach me about X" | Plans and runs a spaced-out course with progress tracking | `ckb-teach-me` | | "Teach me the wiki" / "Teach me about X" | Plans and runs a spaced-out course with progress tracking | `ckb-teach-me` |
| "Onboard me on X" / "Where do I start with X" | Short guided reading order through the graph | `ckb-onboard-me` | | "Onboard me on X" / "Where do I start with X" | Short guided reading order through the graph | `ckb-onboard-me` |
| "Give me a project summary" | Regenerates `PROJECT-OVERVIEW.md` | `ckb-project-summary` | | "Give me a project summary" | Regenerates `PROJECT-OVERVIEW.md` | `ckb-project-summary` |

View file

@ -49,6 +49,15 @@ Nigdy nie kopiuje rzeczywistej zawartości tego projektu (żadnych encji,
danych grafu, notatek). Otrzymujesz świeżą, pustą KB, gotową na pierwszy danych grafu, notatek). Otrzymujesz świeżą, pustą KB, gotową na pierwszy
zrzut do `raw/inbox/`. Zobacz `.agents/skills/ckb-init/SKILL.md`. zrzut do `raw/inbox/`. Zobacz `.agents/skills/ckb-init/SKILL.md`.
Szablon może pochodzić z dwóch miejsc: z plików tego repozytorium albo ze
świeżego, płytkiego klona kanonicznego repozytorium szablonu (lub dowolnego
forka/mirrora, którego URL podasz), pobranego do folderu roboczego.
Powiedz „pull the latest template and set up a KB in \<folder\>” — albo
uruchom skilla spoza jakiejkolwiek KB — a agent najpierw sklonuje
repozytorium, a dopiero potem zbuduje szkielet. Klon jest wyłącznie
roboczy: nowa KB dostaje własną historię gita (agent pyta przed
`git init`), a nie historię szablonu.
Jeśli docelowy folder wygląda już jak baza wiedzy (ma `wiki/` lub Jeśli docelowy folder wygląda już jak baza wiedzy (ma `wiki/` lub
`AGENTS.md`), agent zatrzyma się i zapyta, zanim czegokolwiek dotknie — nie `AGENTS.md`), agent zatrzyma się i zapyta, zanim czegokolwiek dotknie — nie
nadpisze po cichu istniejącej bazy wiedzy. nadpisze po cichu istniejącej bazy wiedzy.
@ -202,7 +211,45 @@ Trwałe braki można też śledzić w `wiki/query-gaps.md`. Dobry wpis o luce je
maleńki: pytanie, gdzie agent szukał i jakie najmniejsze źródło lub strona maleńki: pytanie, gdzie agent szukał i jakie najmniejsze źródło lub strona
sprawiłaby, że odpowiedź będzie dostępna następnym razem. sprawiłaby, że odpowiedź będzie dostępna następnym razem.
### D. Utwórz lokalny zakres projektu ### D. Zapisz decyzję
Gdy zapada jakaś decyzja — wybór technologii, zmiana procesu, polityka —
powiedz:
> „Zapisz decyzję: przenosimy billing na Postgresa. Alice i Bob zdecydowali
> dzisiaj, bo zapytania raportowe zabijały MySQL-a."
Agent zapisze numerowany rekord pod `wiki/decisions/` z decyzją, autorami,
datą, uzasadnieniem, alternatywami i tym, czego dotyczy. Jeśli zastępuje
wcześniejszą decyzję, połączy obie w obu kierunkach i oznaczy starą jako
zastąpioną — nie ruszając jej uzasadnienia. O to, czego nie podasz, dopyta w
jednej turze; jeśli jesteś w środku pracy, powiedz to, a zapisze, co ma, i
wskaże, które pola zostawił otwarte.
Potem pytaj, jak chcesz:
> „Co zdecydowaliśmy w sprawie bazy danych billingu?"
> „Dlaczego używamy Postgresa?"
> „Kto to zdecydował i kiedy?"
> „Które decyzje są wciąż tylko propozycjami?"
> „Co zastąpiło decyzję 3?"
Odpowiedź zawsze przychodzi z informacją kto i kiedy, i wprost mówi, gdy
decyzja jest propozycją, a nie decyzją obowiązującą, albo została już
zastąpiona — żebyś nie działał na czymś, co nie obowiązuje. Zaimplementowane
przez skill `ckb-decide`.
Dwie rzeczy warte zapamiętania:
- **Decyzje są tylko do dopisywania.** „Właściwie zmieniliśmy zdanie" tworzy
*nową* decyzję zastępującą starą; nigdy nie edytuje uzasadnienia starej. To
celowe — historia jest tu sednem. Zwykłe błędy zapisu („powiedziałem Alice,
a było Anna") poprawiane są w miejscu.
- **Propozycja to nie decyzja.** Jeśli sprawa nie została rozstrzygnięta,
zapisywana jest jako `proposed` bez daty decyzji i pojawia się, gdy pytasz,
co jest jeszcze otwarte.
### E. Utwórz lokalny zakres projektu
Gdy jakiś temat, klient, system lub inicjatywa wraca często, poproś: Gdy jakiś temat, klient, system lub inicjatywa wraca często, poproś:
@ -240,8 +287,12 @@ To uruchamia przegląd kondycji całej wiki:
źródła dostęp tylko do odczytu, bo mówi ci, kogo dopytać źródła dostęp tylko do odczytu, bo mówi ci, kogo dopytać
- powtarzające się problemy systemowe trafiają do `wiki/error-book.md` - powtarzające się problemy systemowe trafiają do `wiki/error-book.md`
Naprawia samodzielnie to, co może zrobić bezpiecznie, a resztę zgłasza do Połowa wykrywająca działa jako skrypt Python tylko-do-odczytu
twojej decyzji. Podobnie jak Ingest, na koniec przypomina o przejrzeniu i (`scripts/lint_report.py`), więc ta sama wiki zawsze daje tę samą listę
znalezisk — agent czyta ten raport, a potem wykonuje części wymagające
osądu (supersesja, niejednoznaczne sieroty, wpisy do księgi błędów oraz
decyzja, co naprawić, a co oddać tobie). Naprawia samodzielnie to, co może
zrobić bezpiecznie, a resztę zgłasza do twojej decyzji. Podobnie jak Ingest, na koniec przypomina o przejrzeniu i
synchronizacji. Zaimplementowane przez skill `ckb-lint` synchronizacji. Zaimplementowane przez skill `ckb-lint`
`.agents/skills/ckb-lint/SKILL.md`. `.agents/skills/ckb-lint/SKILL.md`.
@ -450,7 +501,7 @@ raportuje.
Zostaniesz zapytany o liczbę pytań i format (otwarte / jednokrotnego Zostaniesz zapytany o liczbę pytań i format (otwarte / jednokrotnego
wyboru), a następnie przejdziesz przez nie jedno po drugim, z natychmiastową wyboru), a następnie przejdziesz przez nie jedno po drugim, z natychmiastową
informacją zwrotną i bieżącym wynikiem. Nic nie jest zapisywane potem — to informacją zwrotną i bieżącym wynikiem. Nic nie jest zapisywane potem — to
jednorazowy sprawdzian. `.agents/skills/cbk-quiz/SKILL.md`. jednorazowy sprawdzian. `.agents/skills/ckb-quiz/SKILL.md`.
**Prawdziwy kurs, rozłożony w czasie** — powiedz: **Prawdziwy kurs, rozłożony w czasie** — powiedz:
@ -638,6 +689,7 @@ zmieniłeś.
| `libs/<name>/source.yaml` (konektor) | **Tylko ty** | Deklaruje konektor, lokalizację, opcjonalnie jak często ma być odświeżany (`refresh_interval_days:`) i opcjonalnie gdzie znajduje się współdzielony/wcześniej zbudowany indeks (`index:`). Agent go czyta, ale nigdy nie zapisuje — tak jak wszystko inne nadrzędne. | | `libs/<name>/source.yaml` (konektor) | **Tylko ty** | Deklaruje konektor, lokalizację, opcjonalnie jak często ma być odświeżany (`refresh_interval_days:`) i opcjonalnie gdzie znajduje się współdzielony/wcześniej zbudowany indeks (`index:`). Agent go czyta, ale nigdy nie zapisuje — tak jak wszystko inne nadrzędne. |
| `libs/<name>/source.local.yaml` (konektor) | **Ty** (albo agent, tylko gdy wyraźnie poprosisz o zostanie/przestanie bycia administratorem tego źródła) | Osobiste, per-komputer ustawienie `access: write`/`read` — nigdy niecommitowane, nigdy niewidoczne dla innych. Brak = tylko do odczytu, domyślnie. | | `libs/<name>/source.local.yaml` (konektor) | **Ty** (albo agent, tylko gdy wyraźnie poprosisz o zostanie/przestanie bycia administratorem tego źródła) | Osobiste, per-komputer ustawienie `access: write`/`read` — nigdy niecommitowane, nigdy niewidoczne dla innych. Brak = tylko do odczytu, domyślnie. |
| `libs/<name>/{index.md,entities/,graph/,log.md}` (konektor) | Generowane przez agenta, **możesz swobodnie edytować** | Własny indeks agenta dla tego jednego źródła konektora, budowany/odświeżany przez „Index external sources” — ale tylko jeśli masz lokalnie `access: write`; użytkownicy tylko-do-odczytu dostają po prostu pobraną kopię. Strukturalnie ta sama zasada jak przy wierszu `wiki/` poniżej — śmiało popraw wpis ręcznie, a potem uruchom „Lint” (teraz sprawdza też indeksy oparte na konektorach, respektując ten sam podział odczyt/zapis). Ograniczone wyłącznie do tego konektora; nigdy nie wmieszane w `wiki/`. | | `libs/<name>/{index.md,entities/,graph/,log.md}` (konektor) | Generowane przez agenta, **możesz swobodnie edytować** | Własny indeks agenta dla tego jednego źródła konektora, budowany/odświeżany przez „Index external sources” — ale tylko jeśli masz lokalnie `access: write`; użytkownicy tylko-do-odczytu dostają po prostu pobraną kopię. Strukturalnie ta sama zasada jak przy wierszu `wiki/` poniżej — śmiało popraw wpis ręcznie, a potem uruchom „Lint” (teraz sprawdza też indeksy oparte na konektorach, respektując ten sam podział odczyt/zapis). Ograniczone wyłącznie do tego konektora; nigdy nie wmieszane w `wiki/`. |
| `wiki/decisions/` | Generowane przez agenta, **edytuj ostrożnie** | Mechanicznie tak samo jak reszta `wiki/`, ale te strony są z założenia tylko do dopisywania: popraw swobodnie literówkę czy źle przypisane nazwisko, ale nie przepisuj kontekstu ani uzasadnienia decyzji pod późniejszy pogląd — zapisz zamiast tego decyzję zastępującą, żeby historia przetrwała. |
| `wiki/` (strony, `index.md`, `overview.md`, `log.md`, `error-book.md`, `entities/`, `graph/`) | Generowane przez agenta, **możesz swobodnie edytować** | To jedyne miejsce, w którym zarówno agent zapisuje, jak i spodziewa się, że ty też możesz. Śmiało popraw stronę ręcznie — zachowaj tylko pola frontmatteru (lub zaktualizuj `last_updated`) i uruchom potem Lint, jeśli dotknąłeś czegoś, do czego odwołuje się indeks/graf/dziennik. | | `wiki/` (strony, `index.md`, `overview.md`, `log.md`, `error-book.md`, `entities/`, `graph/`) | Generowane przez agenta, **możesz swobodnie edytować** | To jedyne miejsce, w którym zarówno agent zapisuje, jak i spodziewa się, że ty też możesz. Śmiało popraw stronę ręcznie — zachowaj tylko pola frontmatteru (lub zaktualizuj `last_updated`) i uruchom potem Lint, jeśli dotknąłeś czegoś, do czego odwołuje się indeks/graf/dziennik. |
| `outputs/okf/`, `outputs/starlight/` | Agent, **w pełni regenerowane** | Nie edytuj ręcznie — to zignorowane przez git artefakty budowania, cicho nadpisywane przy każdym kolejnym eksporcie. Jeśli coś jest nie tak, popraw stronę wiki, z której to pochodzi, i wyeksportuj ponownie. | | `outputs/okf/`, `outputs/starlight/` | Agent, **w pełni regenerowane** | Nie edytuj ręcznie — to zignorowane przez git artefakty budowania, cicho nadpisywane przy każdym kolejnym eksporcie. Jeśli coś jest nie tak, popraw stronę wiki, z której to pochodzi, i wyeksportuj ponownie. |
| `outputs/teaching/<topic>/` | Agent, stan półtrwały | `plan.md`/`progress.md`, które skill do nauczania czyta i zapisuje między sesjami. Możesz je oglądać kiedy chcesz; ręczna edycja jest możliwa, ale może pomieszać śledzenie „co dalej” — bezpieczniej powiedzieć agentowi, co chcesz zmienić, i pozwolić mu zaktualizować pliki. | | `outputs/teaching/<topic>/` | Agent, stan półtrwały | `plan.md`/`progress.md`, które skill do nauczania czyta i zapisuje między sesjami. Możesz je oglądać kiedy chcesz; ręczna edycja jest możliwa, ale może pomieszać śledzenie „co dalej” — bezpieczniej powiedzieć agentowi, co chcesz zmienić, i pozwolić mu zaktualizować pliki. |
@ -654,10 +706,13 @@ zmieniłeś.
| Powiedz... | Co się dzieje | Skill | | Powiedz... | Co się dzieje | Skill |
|---|---|---| |---|---|---|
| „Set up a new wiki like this one in \<folder\>” | Zakłada świeżą, pustą KB z tym schematem | `ckb-init` | | „Set up a new wiki like this one in \<folder\>” | Zakłada świeżą, pustą KB z tym schematem | `ckb-init` |
| „Pull the ckb repo into \<folder\> and set up the wiki” | Klonuje repozytorium szablonu do katalogu roboczego, a potem zakłada z niego pustą KB | `ckb-init` |
| „Ingest” / „Sync the wiki” / „Update the wiki” | Przetwarza `raw/inbox/` na ustrukturyzowane strony `wiki/` | `ckb-ingest` | | „Ingest” / „Sync the wiki” / „Update the wiki” | Przetwarza `raw/inbox/` na ustrukturyzowane strony `wiki/` | `ckb-ingest` |
| „Zapisz decyzję: ...” / „zdecydowaliśmy ...” | Zapisuje numerowany rekord decyzji pod `wiki/decisions/` | `ckb-decide` |
| „Co zdecydowaliśmy w sprawie X” / „kto zdecydował X” / „co jest otwarte” | Odpowiada z zapisów decyzji, z autorem, datą i statusem | `ckb-decide` |
| „Lint” | Sprawdza kondycję wiki, automatycznie naprawia to, co bezpiecznie może | `ckb-lint` | | „Lint” | Sprawdza kondycję wiki, automatycznie naprawia to, co bezpiecznie może | `ckb-lint` |
| „Sync changes” / „Sync with git” | Commituje, pobiera, rozwiązuje konflikty, wypycha do `origin` | `ckb-sync-changes` | | „Sync changes” / „Sync with git” | Commituje, pobiera, rozwiązuje konflikty, wypycha do `origin` | `ckb-sync-changes` |
| „Quiz me on X” | Jednorazowy, punktowany sprawdzian wiedzy | `cbk-quiz` | | „Quiz me on X” | Jednorazowy, punktowany sprawdzian wiedzy | `ckb-quiz` |
| „Teach me the wiki” / „Teach me about X” | Planuje i prowadzi rozłożony w czasie kurs ze śledzeniem postępu | `ckb-teach-me` | | „Teach me the wiki” / „Teach me about X” | Planuje i prowadzi rozłożony w czasie kurs ze śledzeniem postępu | `ckb-teach-me` |
| „Onboard me on X” / „Where do I start with X” | Krótka, prowadzona kolejność czytania po grafie | `ckb-onboard-me` | | „Onboard me on X” / „Where do I start with X” | Krótka, prowadzona kolejność czytania po grafie | `ckb-onboard-me` |
| „Give me a project summary” | Regeneruje `PROJECT-OVERVIEW.md` | `ckb-project-summary` | | „Give me a project summary” | Regeneruje `PROJECT-OVERVIEW.md` | `ckb-project-summary` |

View file

@ -33,6 +33,7 @@ worked examples for every use case — see [MANUAL.md](MANUAL.md)
│ ├── error-book.md # Compilation errors & derived constraints │ ├── error-book.md # Compilation errors & derived constraints
│ ├── query-gaps.md # Failed or missing-answer questions for future ingest │ ├── query-gaps.md # Failed or missing-answer questions for future ingest
│ ├── projects/ # Optional local query scopes │ ├── projects/ # Optional local query scopes
│ ├── decisions/ # Numbered, append-only decision records + own index.md & log.md
│ ├── entities/ # Typed entity pages (people, projects, concepts) + own index.md │ ├── entities/ # Typed entity pages (people, projects, concepts) + own index.md
│ └── graph/ # Edge lists and relationship data + own index.md │ └── graph/ # Edge lists and relationship data + own index.md
└── workload/ # Session summaries & decisions └── workload/ # Session summaries & decisions
@ -170,6 +171,25 @@ attendance or job title. Where no edge exists yet, retrieval falls back to
authorship evidence and says which of the two grounded the answer, since authorship evidence and says which of the two grounded the answer, since
an inferred expert is a weaker claim than a recorded one. an inferred expert is a weaker claim than a recorded one.
### Decision Records
`wiki/decisions/` holds one numbered page per decision (`NNNN-slug.md`):
what was decided, `decided_by` whom, `decided_on` what date, why, the
alternatives that lost, and what it `affects`. A `status` field
(`proposed`/`accepted`/`rejected`/`superseded`/`reversed`) says whether it is
actually in force, and an optional `review_on` date marks it for revisiting.
Decision pages are **append-only**. When the choice changes, a *new* decision
supersedes the old one — `supersedes`/`superseded_by` are set on both sides,
the old page's status becomes `superseded` or `reversed`, and its original
context and rationale stay untouched. That is what makes "why is it like
this?" answerable years later, and what turns "we use Postgres" into "we use
Postgres, and before that MySQL, changed in September because of reporting".
`decided_by` and `affects` also become graph edges, so "who decided X" and
"what decisions touch Y" are direct lookups. The `ckb-decide` skill records
decisions and answers questions about them; `ckb-lint` checks their structure
(status vocabulary, required dates, two-sided supersession, unique numbers,
overdue reviews).
### Query Gaps ### Query Gaps
If the cascade cannot answer a question, the agent records or proposes a If the cascade cannot answer a question, the agent records or proposes a
short entry in `wiki/query-gaps.md`: what was asked, where it looked, and the short entry in `wiki/query-gaps.md`: what was asked, where it looked, and the
@ -369,9 +389,13 @@ internal schema (`confidence`/`quality`/`retention`/`supersedes`/dual-linking)
that OKF doesn't natively understand. Implemented as a Claude Code Skill — that OKF doesn't natively understand. Implemented as a Claude Code Skill —
see `.agents/skills/ckb-export-okf/SKILL.md` — rather than baked into see `.agents/skills/ckb-export-okf/SKILL.md` — rather than baked into
`CLAUDE.md`/`AGENTS.md`, so the mapping ruleset only loads into context when `CLAUDE.md`/`AGENTS.md`, so the mapping ruleset only loads into context when
actually invoked. `outputs/okf/` is gitignored — it's a fully-regenerated actually invoked. A deterministic Python script
build artifact, so each machine/tool regenerates it on demand rather than (`scripts/export_okf.py`) does the whole transform — frontmatter remapping,
carrying it in git history. link rewriting, index and log regeneration, and an OKF conformance pass over
its own output — so the export is reproducible rather than re-reasoned page
by page; the agent runs it and relays the report. `outputs/okf/` is
gitignored — it's a fully-regenerated build artifact, so each machine/tool
regenerates it on demand rather than carrying it in git history.
### Starlight Export (on demand) ### Starlight Export (on demand)
The wiki can also be exported into an Astro + Starlight-consumable form at The wiki can also be exported into an Astro + Starlight-consumable form at
@ -386,6 +410,16 @@ report. Also on-demand and skill-only — see
`.agents/skills/ckb-export-starlight/SKILL.md`. Like `outputs/okf/`, `.agents/skills/ckb-export-starlight/SKILL.md`. Like `outputs/okf/`,
`outputs/starlight/` is gitignored as a regenerated build artifact. `outputs/starlight/` is gitignored as a regenerated build artifact.
### Decision Log (on demand)
Say "record a decision: ..." (or just "we decided ...") to file a numbered
decision record; ask "what did we decide about X", "who decided that",
"which decisions are still proposed", or "what superseded decision 3" to get
it back with the who, when, and status attached. Recording gathers the
missing fields in a single round rather than an interview, wires the
supersession links in both directions, adds `decided_by`/`affects` graph
edges, and logs to `wiki/decisions/log.md`. See
`.agents/skills/ckb-decide/SKILL.md`.
### Guided Onboarding Tours (on demand) ### Guided Onboarding Tours (on demand)
Ask "onboard me on X" (or "where do I start with X", "mini tour of X") to get Ask "onboard me on X" (or "where do I start with X", "mini tour of X") to get
a short, read-only guided reading order: an overview paragraph plus an a short, read-only guided reading order: an overview paragraph plus an
@ -418,7 +452,7 @@ one-off, scored knowledge check: the agent reads the relevant pages,
generates open or multiple-choice questions grounded in specific wiki generates open or multiple-choice questions grounded in specific wiki
facts, runs them one at a time with immediate feedback and a running facts, runs them one at a time with immediate feedback and a running
score, and closes with a verdict. Stateless — nothing is saved between score, and closes with a verdict. Stateless — nothing is saved between
runs. See `.agents/skills/cbk-quiz/SKILL.md`. runs. See `.agents/skills/ckb-quiz/SKILL.md`.
### Guided Teaching Curriculum (on demand) ### Guided Teaching Curriculum (on demand)
Ask to be taught the wiki ("teach me the wiki", "teach me about X", "run a Ask to be taught the wiki ("teach me the wiki", "teach me about X", "run a

View file

@ -32,6 +32,7 @@ znajdziesz w [MANUAL.pl.md](MANUAL.pl.md) ([English](MANUAL.md)).
│ ├── overview.md # Mapa wysokiego poziomu │ ├── overview.md # Mapa wysokiego poziomu
│ ├── log.md # Główny dziennik zmian (rollup) │ ├── log.md # Główny dziennik zmian (rollup)
│ ├── error-book.md # Błędy kompilacji i wyprowadzone ograniczenia │ ├── error-book.md # Błędy kompilacji i wyprowadzone ograniczenia
│ ├── decisions/ # Numerowane, tylko-dopisywane zapisy decyzji + własny index.md i log.md
│ ├── entities/ # Typowane strony encji (osoby, projekty, koncepcje) + własny index.md │ ├── entities/ # Typowane strony encji (osoby, projekty, koncepcje) + własny index.md
│ └── graph/ # Listy krawędzi i dane relacji + własny index.md │ └── graph/ # Listy krawędzi i dane relacji + własny index.md
└── workload/ # Podsumowania sesji i decyzje └── workload/ # Podsumowania sesji i decyzje
@ -179,6 +180,26 @@ stanowiska. Gdy krawędzi jeszcze nie ma, wyszukiwanie wraca do dowodów
autorstwa i mówi, które z dwóch stanowiło podstawę odpowiedzi, bo domniemany autorstwa i mówi, które z dwóch stanowiło podstawę odpowiedzi, bo domniemany
ekspert to słabsze twierdzenie niż zapisany. ekspert to słabsze twierdzenie niż zapisany.
### Zapisy decyzji
`wiki/decisions/` zawiera jedną numerowaną stronę na decyzję
(`NNNN-slug.md`): co zostało zdecydowane, przez kogo (`decided_by`), kiedy
(`decided_on`), dlaczego, jakie alternatywy odpadły i czego decyzja dotyczy
(`affects`). Pole `status`
(`proposed`/`accepted`/`rejected`/`superseded`/`reversed`) mówi, czy decyzja
faktycznie obowiązuje, a opcjonalna data `review_on` oznacza ją do przeglądu.
Strony decyzji są **tylko do dopisywania**. Gdy wybór się zmienia, *nowa*
decyzja zastępuje starą — `supersedes`/`superseded_by` ustawiane są po obu
stronach, status starej zmienia się na `superseded` lub `reversed`, a jej
pierwotny kontekst i uzasadnienie pozostają nietknięte. To właśnie sprawia,
że „dlaczego jest tak, jak jest?" da się odpowiedzieć po latach, i zamienia
„używamy Postgresa" w „używamy Postgresa, a wcześniej MySQL-a, zmienione we
wrześniu z powodu raportowania". `decided_by` i `affects` stają się też
krawędziami grafu, więc „kto zdecydował o X" i „jakie decyzje dotyczą Y" to
bezpośrednie wyszukania. Skill `ckb-decide` zapisuje decyzje i odpowiada na
pytania o nie; `ckb-lint` sprawdza ich strukturę (słownik statusów, wymagane
daty, dwustronne zastępowanie, unikalne numery, zaległe przeglądy).
### Luki w zapytaniach ### Luki w zapytaniach
Jeśli kaskada nie potrafi odpowiedzieć na pytanie, agent zapisuje lub proponuje Jeśli kaskada nie potrafi odpowiedzieć na pytanie, agent zapisuje lub proponuje
krótki wpis w `wiki/query-gaps.md`: o co pytano, gdzie szukał i jakie krótki wpis w `wiki/query-gaps.md`: o co pytano, gdzie szukał i jakie
@ -411,10 +432,14 @@ bogatszego wewnętrznego schematu
którego OKF natywnie nie rozumie. Zaimplementowane jako Claude Code Skill — którego OKF natywnie nie rozumie. Zaimplementowane jako Claude Code Skill —
zobacz `.agents/skills/ckb-export-okf/SKILL.md` — zamiast być zaszytym w zobacz `.agents/skills/ckb-export-okf/SKILL.md` — zamiast być zaszytym w
`CLAUDE.md`/`AGENTS.md`, dzięki czemu zestaw reguł mapowania ładuje się do `CLAUDE.md`/`AGENTS.md`, dzięki czemu zestaw reguł mapowania ładuje się do
kontekstu tylko wtedy, gdy jest faktycznie wywoływany. `outputs/okf/` jest kontekstu tylko wtedy, gdy jest faktycznie wywoływany. Cały transform —
w `.gitignore` — to w pełni regenerowalny artefakt budowania, więc każda przemapowanie frontmatteru, przepisanie linków, regeneracja indeksów i
maszyna/narzędzie regeneruje go na żądanie zamiast przenosić go w historii dzienników oraz sprawdzenie zgodności z OKF na własnym wyjściu — wykonuje
gita. deterministyczny skrypt Python (`scripts/export_okf.py`), więc eksport jest
odtwarzalny zamiast wyprowadzany na nowo strona po stronie; agent uruchamia
skrypt i przekazuje raport. `outputs/okf/` jest w `.gitignore` — to w pełni
regenerowalny artefakt budowania, więc każda maszyna/narzędzie regeneruje go
na żądanie zamiast przenosić go w historii gita.
### Eksport Starlight (na żądanie) ### Eksport Starlight (na żądanie)
Wiki może też zostać wyeksportowana do formy skonsumowalnej przez Astro + Wiki może też zostać wyeksportowana do formy skonsumowalnej przez Astro +
@ -431,6 +456,16 @@ zobacz `.agents/skills/ckb-export-starlight/SKILL.md`. Podobnie jak
`outputs/okf/`, `outputs/starlight/` jest w `.gitignore` jako regenerowalny `outputs/okf/`, `outputs/starlight/` jest w `.gitignore` jako regenerowalny
artefakt budowania. artefakt budowania.
### Dziennik decyzji (na żądanie)
Powiedz „zapisz decyzję: ..." (albo po prostu „zdecydowaliśmy ..."), żeby
utworzyć numerowany rekord decyzji; zapytaj „co zdecydowaliśmy w sprawie X",
„kto to zdecydował", „które decyzje są wciąż propozycjami" albo „co zastąpiło
decyzję 3", żeby dostać ją z powrotem wraz z autorem, datą i statusem.
Zapisywanie zbiera brakujące pola w jednej turze zamiast wywiadu, ustawia
powiązania zastępowania w obu kierunkach, dodaje krawędzie grafu
`decided_by`/`affects` i zapisuje do `wiki/decisions/log.md`. Zobacz
`.agents/skills/ckb-decide/SKILL.md`.
### Prowadzone wycieczki wprowadzające (na żądanie) ### Prowadzone wycieczki wprowadzające (na żądanie)
Poproś „oprowadź mnie po X" (lub „od czego zacząć z X", „krótka wycieczka Poproś „oprowadź mnie po X" (lub „od czego zacząć z X", „krótka wycieczka
po X") aby otrzymać krótką, tylko-do-odczytu prowadzoną kolejność czytania: po X") aby otrzymać krótką, tylko-do-odczytu prowadzoną kolejność czytania:
@ -468,7 +503,7 @@ strony, generuje pytania otwarte lub jednokrotnego wyboru oparte na
konkretnych faktach z wiki, przeprowadza je jedno po drugim z natychmiastową konkretnych faktach z wiki, przeprowadza je jedno po drugim z natychmiastową
informacją zwrotną i bieżącym wynikiem, a na koniec podaje werdykt. informacją zwrotną i bieżącym wynikiem, a na koniec podaje werdykt.
Bezstanowy — nic nie jest zapisywane między uruchomieniami. Zobacz Bezstanowy — nic nie jest zapisywane między uruchomieniami. Zobacz
`.agents/skills/cbk-quiz/SKILL.md`. `.agents/skills/ckb-quiz/SKILL.md`.
### Prowadzony program nauczania (na żądanie) ### Prowadzony program nauczania (na żądanie)
Poproś o naukę z wiki („teach me the wiki", „naucz mnie o X", „przeprowadź Poproś o naukę z wiki („teach me the wiki", „naucz mnie o X", „przeprowadź

View file

@ -1 +1 @@
1.3.0 1.6.0

0
wiki/decisions/.gitadd Normal file
View file

25
wiki/decisions/index.md Normal file
View file

@ -0,0 +1,25 @@
# Decisions
Decision records — one page per decision, newest number last. Each entry
mirrors the linked page's `tldr`, prefixed with its current status.
Filenames are `NNNN-short-slug.md`, numbered sequentially from `0001`, and
numbers are never reused: a decision that is reversed or superseded keeps its
number and its page, and the newer decision points back at it.
*(No decisions recorded yet — say "record a decision" to add the first one.)*
## Status vocabulary
| Status | Meaning |
|---|---|
| `proposed` | Under discussion; not yet in force. |
| `accepted` | In force. The default for a decision that was actually made. |
| `rejected` | Considered and turned down. Kept so the reasoning isn't relitigated. |
| `superseded` | Replaced by a later decision; `superseded_by` names it. |
| `reversed` | Undone by a later decision that went back to the prior state; `superseded_by` names it. |
## Open questions
Decisions still at `proposed`, and any decision whose `review_on` date has
passed, are the ones worth chasing. `ckb-lint` reports both.

16
wiki/decisions/log.md Normal file
View file

@ -0,0 +1,16 @@
# Decisions Change Log
Changes to pages under `wiki/decisions/` are recorded here rather than in
`wiki/log.md`, per the Recursive Index & Log Convention — each change gets
exactly one home log. `wiki/log.md` carries a pointer line when this log
absorbs a change.
Reverse chronological order, most recent first.
---
## [2026-09-01 21:05] - [CREATE]
- **File Affected:** `wiki/decisions/index.md`, `wiki/decisions/log.md`
- **Description:** Created the decision-log scaffold: an index listing every decision by number with its status and one-line summary, the status vocabulary (proposed / accepted / rejected / superseded / reversed), and this log. No decisions recorded yet.
- **Source:** Chat conversation requesting a decision log feature.
---

View file

@ -4,6 +4,7 @@ Edge list and relationship data for the knowledge graph, keyed by entity page.
* `edges.json` - Typed relationships between entity pages. Created on the first ingest that extracts entities. * `edges.json` - Typed relationships between entity pages. Created on the first ingest that extracts entities.
* Structural: `uses`, `depends_on`, `caused`, `contradicts`, `supersedes`. * Structural: `uses`, `depends_on`, `caused`, `contradicts`, `supersedes`.
* Decision edges: `decided_by` (decision page -> the person who made the call) and `affects` (decision page -> the entity, project, or system it constrains). These make "who decided X", "what decisions touch Y", and "what changed after Z" direct graph lookups. Written by `ckb-decide` when a decision is recorded.
* People-to-topic: `has_expertise_in` (demonstrated ability to answer questions on a topic) and `owns` (declared responsibility for a system, area, or decision). These make "who knows about X" and "who owns X" a direct graph lookup rather than a full-text guess. Recorded only from demonstrated evidence, never inferred from attendance or job title. * People-to-topic: `has_expertise_in` (demonstrated ability to answer questions on a topic) and `owns` (declared responsibility for a system, area, or decision). These make "who knows about X" and "who owns X" a direct graph lookup rather than a full-text guess. Recorded only from demonstrated evidence, never inferred from attendance or job title.
*(No edges recorded yet — populated on the next ingest.)* *(No edges recorded yet — populated on the next ingest.)*

View file

@ -1,5 +1,5 @@
--- ---
kb_schema_version: "1.3" kb_schema_version: "1.4"
--- ---
# Knowledge Base Index # Knowledge Base Index
@ -13,6 +13,7 @@ kb_schema_version: "1.3"
| [Error Book](error-book.md) | Known compilation errors and fixes | Debugging a bad ingest or lint issue | | [Error Book](error-book.md) | Known compilation errors and fixes | Debugging a bad ingest or lint issue |
| [Projects](projects/index.md) | Optional local query scopes grouping related wiki pages, sources, libs, and graph areas | Narrowing search to a team, topic, client, system, or initiative | | [Projects](projects/index.md) | Optional local query scopes grouping related wiki pages, sources, libs, and graph areas | Narrowing search to a team, topic, client, system, or initiative |
| [Query Gaps](query-gaps.md) | Questions the wiki could not answer yet, used to drive demand-driven ingest | Tracking failed searches, missing knowledge, future ingest targets | | [Query Gaps](query-gaps.md) | Questions the wiki could not answer yet, used to drive demand-driven ingest | Tracking failed searches, missing knowledge, future ingest targets |
| [Decisions](decisions/index.md) | Numbered decision records — what was decided, by whom, when, why, and what it superseded | Asking why something is the way it is, who decided it, when, what changed it, or what is still open |
| [Entities](entities/index.md) | Typed entity pages (people, projects, libraries, concepts) | Looking up a specific person, project, library, or concept | | [Entities](entities/index.md) | Typed entity pages (people, projects, libraries, concepts) | Looking up a specific person, project, library, or concept |
| [Graph](graph/index.md) | Edge lists and relationship data between entities | Finding what depends on / relates to a given entity | | [Graph](graph/index.md) | Edge lists and relationship data between entities | Finding what depends on / relates to a given entity |
@ -24,3 +25,11 @@ extracted during ingest. See [Entities index](entities/index.md) /
graph index. graph index.
*(Agent populates this as entities are extracted.)* *(Agent populates this as entities are extracted.)*
## Decision Records
Pages in `decisions/` are numbered decision records (`NNNN-slug.md`). Unlike
entity pages, they are append-only: a decision is never rewritten to match a
later change of mind — a new decision supersedes it and both stay on the
record. See [Decisions index](decisions/index.md) /
[[decisions/index]]. Say "record a decision" to add one.

View file

@ -1,5 +1,31 @@
# Wiki Change Log # Wiki Change Log
## [2026-09-01 21:05] - [CREATE]
- **File Affected:** `wiki/decisions/` (directory)
- **Description:** Created the decision-records directory — one numbered, append-only page per decision, recorded and queried by the new `ckb-decide` skill. Its own contents are logged in `wiki/decisions/log.md`.
- **Source:** Chat conversation requesting a decision log feature.
---
## [2026-09-01 21:05] - [UPDATE]
- **File Affected:** `wiki/index.md`
- **Description:** Added the Decisions routing row and a "Decision Records" section, and bumped `kb_schema_version` from `1.3` to `1.4` — a minor bump for the additive `decisions/` scaffold and the optional `status`/`decided_on`/`decided_by`/`affects`/`review_on` fields on `type: decision` pages. No existing field or convention changed meaning.
- **Source:** Chat conversation requesting a decision log feature.
---
## [2026-09-01 21:05] - [UPDATE]
- **File Affected:** `wiki/overview.md`
- **Description:** Documented `wiki/decisions/` in the directory tree, added a "Decision Records" section covering the append-only rule and the decision-specific frontmatter, and listed `decisions/` among the recursive indexes.
- **Source:** Chat conversation requesting a decision log feature.
---
## [2026-09-01 21:05] - [UPDATE]
- **File Affected:** `wiki/graph/index.md`
- **Description:** Documented two new optional edge types, `decided_by` (decision -> person) and `affects` (decision -> constrained entity), so "who decided X" and "what decisions touch Y" become direct graph lookups.
- **Source:** Chat conversation requesting a decision log feature.
---
- See wiki/decisions/log.md for decision-record changes on this date.
## [2026-07-30 08:40] - [UPDATE] ## [2026-07-30 08:40] - [UPDATE]
- **File Affected:** `wiki/index.md` - **File Affected:** `wiki/index.md`
- **Description:** Bumped `kb_schema_version` from `1.2` to `1.3` — a minor bump for the additive `has_expertise_in`/`owns` graph edge types. No existing field or convention changed meaning. - **Description:** Bumped `kb_schema_version` from `1.2` to `1.3` — a minor bump for the additive `has_expertise_in`/`owns` graph edge types. No existing field or convention changed meaning.

View file

@ -1,7 +1,7 @@
--- ---
type: overview type: overview
tldr: High-level map of the cascade knowledge base's directory structure, frontmatter schema, and layering rules. tldr: High-level map of the cascade knowledge base's directory structure, frontmatter schema, and layering rules.
last_updated: 2026-07-13 last_updated: 2026-09-01
--- ---
# Knowledge Base Overview # Knowledge Base Overview
@ -27,22 +27,35 @@ When the same entity exists in multiple layers, the local version wins.
├── tmp/ # Temporary files, caches (gitignored) ├── tmp/ # Temporary files, caches (gitignored)
├── wiki/ # Local structured wiki (agent-managed) ├── wiki/ # Local structured wiki (agent-managed)
│ ├── query-gaps.md # Failed or missing-answer questions for demand-driven ingest │ ├── query-gaps.md # Failed or missing-answer questions for demand-driven ingest
│ ├── projects/ # Optional local query scopes │ ├── projects/ # Optional local query scopes
│ ├── entities/ # Typed entity pages │ ├── decisions/ # Numbered, append-only decision records
│ └── graph/ # Edge lists and relationship data │ ├── entities/ # Typed entity pages
│ └── graph/ # Edge lists and relationship data
└── workload/ # Summaries of discussions and decisions └── workload/ # Summaries of discussions and decisions
``` ```
## Page Frontmatter ## Page Frontmatter
Every wiki page carries YAML frontmatter with a required `type` field, plus Every wiki page carries YAML frontmatter with a required `type` field, plus
optional `resource`, `tldr`, `confidence`, `quality`, `supersedes`, optional `resource`, `tldr`, `confidence`, `quality`, `supersedes`,
`freshness_window_days`, and `retention`. `wiki/index.md` additionally `freshness_window_days`, and `retention`. Pages with `type: decision` add
`status`, `decided_on`, `decided_by`, `affects`, and `review_on`. `wiki/index.md` additionally
declares `kb_schema_version` for the bundle as a whole. declares `kb_schema_version` for the bundle as a whole.
See AGENTS.md for the full schema. See AGENTS.md for the full schema.
## Decision Records
`wiki/decisions/` holds one numbered page per decision (`NNNN-slug.md`),
recording what was decided, by whom, on what date, why, and which earlier
decision it supersedes or reverses. Decision pages are **append-only**: the
substance is never rewritten to match a later change of mind — a new decision
supersedes the old one and both stay on the record, so "why is it like this?"
keeps its original answer. Decision-specific frontmatter is `status`,
`decided_on`, `decided_by`, and optionally `affects` and `review_on`; the
existing `supersedes`/`superseded_by` pair carries the history. The
`ckb-decide` skill records them and answers questions about them.
## Recursive Indexes ## Recursive Indexes
`entities/`, `graph/`, and optional topic folders such as `projects/` each `entities/`, `graph/`, `decisions/`, and optional topic folders such as
carry their own `index.md` so navigation stays lazy — read `wiki/index.md` `projects/` each carry their own `index.md` so navigation stays lazy — read `wiki/index.md`
first, then only descend into a subdirectory index if its contents are first, then only descend into a subdirectory index if its contents are
relevant to the current task. relevant to the current task.

View file

@ -0,0 +1,44 @@
## 2026-09-01 20:25 CEST
- Ran `ckb-sync-changes`: committed a local `workload/2026-07-29_summary.md` edit, merged 4 commits from `origin/main` (one conflict in that file, resolved by keeping the remote superset), pushed; `origin/main` now at `8174a54`.
- Updated `.agents/skills/ckb-init/SKILL.md` so init can pull the template repo into a scratch folder instead of only copying from the local working tree:
- New Step 3 "Resolve the template source" (shallow clone of the canonical repo or a user-supplied fork/mirror URL vs. the local working tree), with scratch-path guidance and an explicit rule against cloning into the target itself.
- New Step 11 "Initialize the new KB's own git history (ask first)"; report step now names the template source and git-init outcome.
- Fixed drift while there: skill list (`ckb-quiz` typo, added `ckb-retrieve`/`ckb-index-external`), `kb_schema_version` no longer hard-coded to 1.1, `wiki/projects/` and `wiki/query-gaps.md` added to the scaffold, `.gitignore` snapshot refreshed with the connector `libs/` rules.
- Added clone-related edge cases (clone failure with no local fallback, clone-in-place request, target already a git repo, stale scratch dir).
- Propagated the change to `AGENTS.md` skill routing table and `MANUAL.md`/`MANUAL.pl.md` (prose + "say this" tables).
- Bumped root `VERSION` 1.3.0 → 1.4.0 (template/tooling layer); `wiki/index.md` `kb_schema_version` stays 1.3 — no wiki content contract change.
## 2026-09-01 20:35 CEST
- User asked for a review of the skill set with a proposed model per skill.
- Reviewed all 13 `.agents/skills/ckb-*` skills (plus the 7 personal skills under `~/.claude/skills/`) and classified each by reasoning load vs. mechanical load.
- Proposal: Opus 5 for `ckb-ingest`, `ckb-retrieve`, `ckb-upgrade`, `ckb-project-summary`, `ckb-teach-me`; Sonnet 5 for `ckb-index-external`, `ckb-lint`, `ckb-init`, `ckb-sync-changes`, `ckb-export-okf`, `ckb-onboard-me`, `ckb-quiz`; Haiku 4.5 for `ckb-export-starlight` (script-driven).
- Noted that SKILL.md frontmatter carries only `name`/`description` here and Claude Code skills have no `model` field — model choice is per-session (`/model`) or per-subagent, so applying the proposal means either switching model before invoking or wrapping a skill in a subagent definition with `model:` frontmatter.
- Also flagged: `ckb-export-okf` does its page transform by hand while `ckb-export-starlight` delegates to a Python script — the OKF one is the obvious candidate to scriptify (and would then drop to Haiku).
- No files changed by this review.
## 2026-09-01 20:45 CEST
- User asked to remove the duplicate personal `quiz` skill and to scriptify the skills whose work is mechanical.
- Deleted `~/.claude/skills/quiz/` (byte-identical to `ckb-quiz` apart from the `name:` field and the license footer); a copy was kept in the session scratchpad only.
- Scriptified `ckb-export-okf`: added `.agents/skills/ckb-export-okf/scripts/export_okf.py` (stdlib-only, read-only outside its output dir) doing the whole transform — frontmatter remap, wikilink stripping, `/wiki/` link rewriting, index and log regeneration from the tree, plus an OKF conformance pass over its own output. `--check` validates into a temp dir without writing. Verified byte-identical across two runs.
- Rewrote `ckb-export-okf/SKILL.md` around the script (run it, relay the report, distinguish `SOURCE ISSUE:` = fix the wiki from `NONCONFORMANT:` = fix the script), keeping the mapping table as reference documentation.
- Scriptified the detection half of `ckb-lint`: added `.agents/skills/ckb-lint/scripts/lint_report.py` — strictly read-only, covering checks 1/2/3/4/6/7/8/10 across `wiki/` and every connector-backed `libs/<name>/` (reporting each one's `access:` level). Supersession (5), error-book (9), and every auto-fix-vs-report decision deliberately stay with the model. Added Step 0 to `ckb-lint/SKILL.md` and rewrote the mechanical checks to describe what the findings mean rather than how to detect them.
- Tested the checker against a synthetic tree covering unparseable frontmatter, missing `type`, stale/low-confidence/archive-candidate pages, orphans, dangling graph edges, missing index entries, a change double-logged in root and subdirectory logs, and an invalid `source.yaml`. Against the real wiki it finds one genuine issue: `wiki/query-gaps.md` is 4 days past its 30-day freshness window.
- Propagated to `README.md`/`README.pl.md` (OKF section now describes the script), `MANUAL.md`/`MANUAL.pl.md` (Lint section notes the read-only checker), and `ckb-init` (skill-set copy now names all three support scripts).
- Bumped root `VERSION` 1.4.0 → 1.5.0 (template/tooling layer); wiki `kb_schema_version` stays 1.3 — no content contract change.
- Decided against scriptifying anything else: `ckb-init`/`ckb-upgrade` are interactive and decision-heavy, and the rest (`ingest`, `retrieve`, `project-summary`, `teach-me`, `onboard-me`, `quiz`) are judgment work with no fixed ruleset to encode.
## 2026-09-01 21:10 CEST
- User asked for a decision-log feature: record decisions with who/when/supersession and query them back.
- Designed decisions as their own page kind rather than entity pages, because a decision is a point-in-time record with a lifecycle, not a description that gets rewritten as understanding improves. Core rule: **decision pages are append-only** — a changed mind is a new decision superseding the old one, with the old page's context and rationale left intact.
- Wiki layer: created `wiki/decisions/` with `index.md` (status vocabulary: proposed / accepted / rejected / superseded / reversed) and its own `log.md`. Pages are `NNNN-slug.md`, numbered from 0001, numbers never reused.
- Schema: `type: decision` adds `status`, `decided_on`, `decided_by`, and optional `affects` / `review_on`; the existing `supersedes`/`superseded_by` pair carries decision history (must be set on both sides). Bumped `kb_schema_version` 1.3 → 1.4 (additive) and root `VERSION` 1.5.0 → 1.6.0.
- New skill `ckb-decide` owns both halves: recording (collect facts, ask for gaps in one round rather than an interview, check for a decision it supersedes, allocate the number, write page, update index + graph edges + logs) and lookup (index first, read superseded chains as history, answer with who/when/status attached, never reconstruct an unrecorded decision).
- `ckb-lint` now checks decision structure mechanically via `lint_report.py`: status vocabulary, `decided_on` where the status implies one, `decided_by` present (`unknown` counts), resolvable `affects` targets, two-sided supersession, `superseded`/`reversed` matched by a `superseded_by`, unique four-digit numbers, and overdue `review_on`. The semantic call — whether one decision genuinely replaces another — stays with the model.
- `ckb-ingest` now routes decisions found in raw material to `ckb-decide`'s format, with explicit guardrails against filing a proposal as accepted or guessing a decider. `ckb-retrieve` gained `wiki/decisions/index.md` as cascade step 4 with a handoff rule.
- Graph gained `decided_by` (decision → person) and `affects` (decision → constrained entity) edge types.
- Docs updated in both languages (README/README.pl feature + skill sections, MANUAL/MANUAL.pl new §2.D walkthrough and "say this" table rows, ownership table row noting the append-only convention), plus `ckb-init` (scaffold + skill list). Also fixed five stale `cbk-quiz` path references left from the upstream rename.
- End-to-end tested on a scratch copy: a sample decision record passes lint clean and exports to OKF conformantly. That test surfaced a real bug in `export_okf.py` — a Rule B log line naming a directory (`wiki/decisions/` (directory)) was being turned into a broken intra-bundle link; `files_to_links` now only linkifies entries that name an actual `.md` file.