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:
parent
8174a54cee
commit
65b1e422b3
23 changed files with 2186 additions and 243 deletions
290
.agents/skills/ckb-decide/SKILL.md
Normal file
290
.agents/skills/ckb-decide/SKILL.md
Normal 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>.*
|
||||
|
|
@ -17,9 +17,16 @@ dual-linking, the cascade layers). This is a one-way, on-demand export —
|
|||
`wiki/` stays the authoritative source; `outputs/okf/` is always a derived
|
||||
artifact of it, never edited by hand and never fed back in.
|
||||
|
||||
The whole transform — frontmatter remapping, link rewriting, index and log
|
||||
regeneration, conformance validation — is done by a deterministic Python
|
||||
script, not by reading and rewriting every page by hand. The mapping is a
|
||||
fixed ruleset over a wiki that will keep growing, and a mechanical transform
|
||||
like this belongs in code, not in per-page model reasoning. Nothing in this
|
||||
skill needs an LLM to run correctly; the model's job is to invoke the script
|
||||
and relay its report.
|
||||
|
||||
This skill only runs when explicitly invoked — it is deliberately not part
|
||||
of the always-loaded `CLAUDE.md`/`AGENTS.md` Ingest/Lint workflows, so its
|
||||
mapping ruleset doesn't tax every session's context.
|
||||
of the always-loaded `CLAUDE.md`/`AGENTS.md` Ingest/Lint workflows.
|
||||
|
||||
## Trigger phrases
|
||||
|
||||
|
|
@ -31,154 +38,120 @@ Use this skill when the user says things like:
|
|||
|
||||
## How to run this skill
|
||||
|
||||
### Step 1 — Read the source tree
|
||||
### Step 1 — Run the script
|
||||
|
||||
Read every file under `wiki/` (the whole tree, including `entities/`,
|
||||
`graph/`, and any other subdirectories present). Note which filenames are
|
||||
`index.md` or `log.md` at any depth (these are OKF's two reserved names) —
|
||||
everything else is a concept document.
|
||||
From the repository root:
|
||||
|
||||
### Step 2 — Clear and rebuild `outputs/okf/`
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/export_okf.py"
|
||||
```
|
||||
|
||||
`outputs/okf/` is a pure, fully-regenerated build artifact. Delete its
|
||||
current contents (if any) and rebuild it from scratch by mirroring `wiki/`'s
|
||||
directory structure 1:1 under `outputs/okf/`. Never hand-patch an existing
|
||||
export — always regenerate the whole bundle, so it can't accumulate stale
|
||||
files left over from wiki pages that were since renamed or removed.
|
||||
Resolve `<skill-dir>` to this skill's own directory. Flags:
|
||||
|
||||
Do not touch anything outside `outputs/okf/`. Do not run any `git`
|
||||
commands — regenerating files is this skill's job; staging and committing
|
||||
the result is a separate, explicit action left to the user.
|
||||
- `--check` — build into a temporary directory, run the conformance checks,
|
||||
print the report, and write nothing to `outputs/`. Use this when the user
|
||||
wants to know whether the wiki *would* export cleanly without touching the
|
||||
working tree (e.g. as part of a review, or before a lint pass).
|
||||
- `--out DIR` — write somewhere other than `outputs/okf` (rarely needed).
|
||||
|
||||
### Step 3 — Transform concept documents (every file that isn't `index.md`/`log.md`)
|
||||
The script deletes and rebuilds the output directory on every run, so the
|
||||
bundle can never accumulate stale files from pages that were since renamed
|
||||
or removed. It touches nothing outside the output directory and runs no
|
||||
`git` commands.
|
||||
|
||||
For each such file, rewrite its frontmatter using this field mapping:
|
||||
The exit code is `0` when the generated bundle conforms and `1` when it does
|
||||
not — the report always prints either way, so read it rather than relying on
|
||||
the exit code alone.
|
||||
|
||||
### Step 2 — Relay the script's report
|
||||
|
||||
The script prints, and you should summarize back to the user:
|
||||
|
||||
- Concept documents exported, and `index.md`/`log.md` files regenerated.
|
||||
- Wikilinks stripped and `/wiki/` links rewritten to bundle-root paths.
|
||||
- How many `linked/`/`libs/` cross-cascade references were left unconverted
|
||||
— those won't resolve if the bundle is ever moved or shipped standalone,
|
||||
which is spec-compliant (OKF tolerates broken links) but worth stating.
|
||||
- Any `SOURCE ISSUE:` lines. These are problems in `wiki/` itself, not in
|
||||
the export — most commonly a page with no `type`, which the bundle exports
|
||||
as `type: unknown`. Surface them; the fix belongs in the source wiki (run
|
||||
`ckb-lint`), not in the export.
|
||||
- Any `NONCONFORMANT:` lines. These are bugs in the export — an
|
||||
intra-bundle link that doesn't resolve, an index with the wrong
|
||||
frontmatter shape, a malformed log header. Do not report the run as
|
||||
successful when these appear; investigate the script rather than
|
||||
hand-patching `outputs/okf/`.
|
||||
- A reminder that `outputs/okf/` has been regenerated on disk but not staged
|
||||
or committed — that's a separate step for the user when they're ready to
|
||||
publish the update. (`outputs/okf` is gitignored by default in this
|
||||
template, so "publish" usually means copying the bundle elsewhere rather
|
||||
than committing it in place.)
|
||||
|
||||
## What the transform does (for context, not something you need to re-derive)
|
||||
|
||||
**Concept documents** (every `.md` that isn't `index.md`/`log.md`, including
|
||||
`overview.md`, `error-book.md`, and `query-gaps.md` — only `index.md` and
|
||||
`log.md` are OKF reserved names):
|
||||
|
||||
| wiki/ field | OKF output field | Rule |
|
||||
|---|---|---|
|
||||
| `type` | `type` | passthrough (required on both sides — every wiki page should already have one; if a page is somehow missing it, use `type: unknown` and flag it in the final report rather than skipping the file) |
|
||||
| `resource` | `resource` | passthrough |
|
||||
| `type` | `type` | passthrough; a page with no `type` exports as `unknown` and is reported as a source issue |
|
||||
| *(derive)* | `title` | the first `# H1` in the body, else the slugified filename (`foo-bar.md` → "Foo Bar") |
|
||||
| `tldr` | `description` | rename |
|
||||
| `last_updated` | `timestamp` | passthrough as-is (bare `YYYY-MM-DD` is a valid ISO 8601 date — do not fabricate a time-of-day that was never recorded) |
|
||||
| *(none — derive)* | `title` | the first `# H1` heading in the body; if there is none, slugify the filename (e.g. `foo-bar.md` → "Foo Bar") |
|
||||
| `confidence`, `quality`, `retention`, `supersedes`, `superseded_by`, `freshness_window_days` | same key names | passthrough unchanged, as OKF extension fields — OKF requires consumers to tolerate unrecognized keys, so these ride along as-is |
|
||||
| `kb_schema_version` (only ever present on `wiki/index.md`) | *(omit)* | OKF's root `index.md` frontmatter is spec-limited to `okf_version` only; this key has no valid home in the bundle and is dropped, not relocated |
|
||||
| *(none)* | `tags` | omit — there is no source field to derive it from; do not fabricate |
|
||||
| `resource` | `resource` | passthrough |
|
||||
| `last_updated` | `timestamp` | passthrough as-is (bare `YYYY-MM-DD` is valid ISO 8601 — no time-of-day is fabricated) |
|
||||
| `confidence`, `quality`, `retention`, `supersedes`, `superseded_by`, `freshness_window_days` | same keys | passthrough as OKF extension fields, which consumers must tolerate |
|
||||
| `kb_schema_version` | *(dropped)* | the root `index.md`'s frontmatter is spec-limited to `okf_version`; this key has no valid home in the bundle |
|
||||
| *(none)* | `tags` | omitted — no source field to derive it from |
|
||||
|
||||
Then rewrite the body's links:
|
||||
**Links:** the `[[Wikilink]]` half of every dual-link is dropped, keeping the
|
||||
markdown half (a bare wikilink with no markdown twin degrades to its plain
|
||||
label). Root-absolute `/wiki/entities/foo.md` becomes `/entities/foo.md`,
|
||||
since the bundle root is `outputs/okf/`, not the repo root. Relative links
|
||||
need no change — the export mirrors `wiki/`'s tree exactly.
|
||||
`linked/<name>/...` and `libs/<name>/...` references are left untouched and
|
||||
counted.
|
||||
|
||||
1. **Strip wikilinks.** Every dual-link in the source looks like
|
||||
`[[Page Name]] / [Page Name](path.md)`. Delete the `[[...]]` half
|
||||
(and the ` / ` separator if present), keep only the
|
||||
`[text](path.md)` half. OKF has no wikilink concept.
|
||||
2. **Rewrite repo-root-absolute intra-wiki links.** A link like
|
||||
`/wiki/entities/foo.md` becomes `/entities/foo.md` — the OKF bundle
|
||||
root is `outputs/okf/`, not the repo root, so the leading `/wiki`
|
||||
segment must be stripped. Plain relative links (e.g.
|
||||
`../entities/foo.md`) need no change, since the export mirrors
|
||||
`wiki/`'s tree exactly.
|
||||
3. **Leave `linked/<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.
|
||||
**Indexes:** regenerated from the tree rather than transcribed from the
|
||||
source, so they can't drift. The root `index.md` carries only
|
||||
`okf_version: "0.1"` (the one documented exception to "index.md has no
|
||||
frontmatter"); nested indexes carry none. Each body is an H1 plus a flat
|
||||
`* [Title](path) - description` bullet list of that directory's direct
|
||||
children, sorted by path, with each page's own `description` as the
|
||||
description text. The source's "Use when" column and prose sections are
|
||||
dropped — they are Claude-agent lazy-loading optimizations with no meaning
|
||||
to a generic OKF consumer. An empty list is spec-valid.
|
||||
|
||||
### Step 4 — Regenerate `index.md` files
|
||||
**Logs:** entries are regrouped under `## YYYY-MM-DD` headers (date only,
|
||||
newest date first) as `* **Verb**: [affected files] — description (source: ...)`.
|
||||
`CREATE` → Creation, `UPDATE` → Update, `DELETE` → Deprecation, anything else
|
||||
→ Update. The `- **Source:**` line rides along as the trailing parenthetical
|
||||
rather than being dropped — it's provenance worth keeping. The `---`
|
||||
separators between entries are dropped; they aren't part of OKF's log format.
|
||||
|
||||
**Root `outputs/okf/index.md`:** frontmatter contains *only*
|
||||
`okf_version: "0.1"` — this is the one documented exception to "index.md
|
||||
has no frontmatter" in the OKF spec. Body is a flat bullet list, one line
|
||||
per linked page or subdirectory, in the form
|
||||
`* [Title](path) - one-line description` (reuse each page's `description`,
|
||||
i.e. the renamed `tldr`, as the description text). Drop the source
|
||||
`wiki/index.md`'s "Use when" column entirely — it's a Claude-agent-only
|
||||
lazy-loading optimization with no meaning to a generic OKF consumer — and
|
||||
drop any prose sections (like the source's "## Entity Pages" paragraph).
|
||||
|
||||
**Every nested `index.md`** (`entities/index.md`, `graph/index.md`, and
|
||||
any future topic-folder index): no frontmatter, ever. Strip the source's
|
||||
explanatory prose and italic placeholder notes (e.g.
|
||||
"*(No entities yet — populated on the next ingest.)*"), keeping only the
|
||||
bullet list — an empty list is spec-valid if the directory has no pages
|
||||
yet.
|
||||
|
||||
### Step 5 — Regenerate `log.md` files
|
||||
|
||||
For each `log.md` in the source tree (root and any subdirectory), reformat
|
||||
its entries into OKF's convention: group entries by date under
|
||||
`## YYYY-MM-DD` headers (date only, no time, newest date first), each
|
||||
entry as a bullet:
|
||||
|
||||
```
|
||||
* **Verb**: description with [links](/path/to.md) (source: original source text).
|
||||
```
|
||||
|
||||
Map the source's `ACTION TYPE` to an OKF verb: `CREATE` → Creation,
|
||||
`UPDATE` → Update, `DELETE` → Deprecation, `RESTRUCTURE` → Update (fallback
|
||||
for anything else). Fold the source entry's `- **Source:**` line in as the
|
||||
trailing parenthetical shown above rather than dropping it — it's useful
|
||||
provenance information and OKF's format has room for free text after the
|
||||
verb. Drop the `---` horizontal-rule separators between entries; they are
|
||||
not part of OKF's log format.
|
||||
|
||||
### Step 6 — Handle non-reserved special pages
|
||||
|
||||
`wiki/error-book.md` and `wiki/overview.md` are not OKF reserved
|
||||
filenames (only `index.md` and `log.md` are) — export them as ordinary
|
||||
concept documents using the Step 3 rules like any other page. They should
|
||||
already carry `type`/`tldr`/`last_updated` frontmatter; if you ever find
|
||||
one that doesn't, that's a lint problem in the source wiki — flag it in
|
||||
the report rather than silently patching the export.
|
||||
|
||||
### Step 7 — Validate the output bundle
|
||||
|
||||
Before reporting done, re-check the *generated* `outputs/okf/` bundle
|
||||
against OKF's own conformance criteria (the same shape of conformance check
|
||||
`ckb-lint` runs against the source wiki):
|
||||
|
||||
- Every non-reserved `.md` file has frontmatter with a non-empty `type`.
|
||||
- The root `index.md`'s frontmatter contains only `okf_version` (or is
|
||||
empty); every nested `index.md` has no frontmatter at all.
|
||||
- Every `log.md` entry matches the `## YYYY-MM-DD` header pattern.
|
||||
- Every intra-bundle link (i.e. every link you did NOT leave untouched in
|
||||
Step 3.3) resolves to a file that actually exists in `outputs/okf/`. If
|
||||
the rewrite in Step 3.2 produced a link that doesn't resolve, that's a
|
||||
bug in this export, not an acceptable "broken link" — fix it before
|
||||
reporting done, don't just note it as a warning.
|
||||
|
||||
### Step 8 — Report
|
||||
|
||||
Tell the user:
|
||||
- How many concept documents were exported.
|
||||
- How many `index.md`/`log.md` files were regenerated.
|
||||
- How many `linked/`/`libs/` cross-cascade references were left
|
||||
unconverted (Step 3.3), since those won't resolve if the bundle is ever
|
||||
moved or shipped standalone.
|
||||
- Any conformance issues found in Step 7 and whether they were fixed.
|
||||
- A reminder that `outputs/okf/` has been regenerated on disk but not
|
||||
staged or committed — that's a separate step for the user to take when
|
||||
ready to publish the update.
|
||||
**Validation** runs against the generated bundle before the report prints:
|
||||
every non-reserved page has a non-empty `type`; the root index has only
|
||||
`okf_version` and nested indexes have no frontmatter; every `log.md` header
|
||||
matches `## YYYY-MM-DD`; and every intra-bundle link resolves to a file that
|
||||
exists (cross-cascade `linked/`/`libs/` references are exempt by design).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Empty `wiki/entities/` or `wiki/graph/`** (as of writing, both are
|
||||
empty): still regenerate their `index.md` as an empty bullet list under
|
||||
`outputs/okf/entities/` and `outputs/okf/graph/` — an empty index is
|
||||
spec-valid, don't skip the directory entirely.
|
||||
- **A wiki page missing `type`:** per Step 3, use `type: unknown` and flag
|
||||
it in the report — this indicates the source wiki itself failed lint's
|
||||
conformance check, which is worth surfacing
|
||||
to the user rather than quietly masking it in the export.
|
||||
- **A future `wiki/archived/` directory:** export it like any other
|
||||
subdirectory (mirror the structure, apply the same per-file rules) —
|
||||
OKF has no notion of archival status; `retention`/`freshness_window_days`
|
||||
- **Empty `wiki/entities/` or `wiki/graph/`:** their `index.md` is still
|
||||
regenerated, as an H1 with an empty bullet list. An empty index is
|
||||
spec-valid; the directory is never skipped.
|
||||
- **A future `wiki/<newtopic>/` subdirectory:** handled automatically — the
|
||||
script discovers directories dynamically, mirrors them, and generates an
|
||||
index for each. No script changes needed.
|
||||
- **A future `wiki/archived/`:** exported like any other subdirectory. OKF
|
||||
has no notion of archival status; `retention`/`freshness_window_days`
|
||||
already ride along as extension fields for any consumer that cares.
|
||||
- **Re-running the skill with no wiki changes since the last run** should
|
||||
produce byte-identical output — if you notice non-determinism (e.g. from
|
||||
arbitrary ordering when listing directory entries), sort filenames
|
||||
alphabetically wherever you're generating a bullet list or walking a
|
||||
directory, so re-runs are stable.
|
||||
- **Non-markdown files in `wiki/`** (e.g. `graph/edges.json`): copied
|
||||
verbatim into the same relative position and listed in their directory's
|
||||
index. Dotfiles (`.gitadd`) are skipped.
|
||||
- **Re-running with no wiki changes:** produces byte-identical output — every
|
||||
directory walk and generated list is sorted. If a run is ever
|
||||
nondeterministic, that's a bug in the script, not expected behavior.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
529
.agents/skills/ckb-export-okf/scripts/export_okf.py
Normal file
529
.agents/skills/ckb-export-okf/scripts/export_okf.py
Normal 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())
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
---
|
||||
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
|
||||
|
||||
## Purpose
|
||||
|
||||
Copy this project's Cascade Knowledge Base *schema* - not its content - into
|
||||
a new target folder: the directory structure, the `AGENTS.md`/`CLAUDE.md`
|
||||
Copy the Cascade Knowledge Base *schema* - not its content - into a new
|
||||
target folder: the directory structure, the `AGENTS.md`/`CLAUDE.md`
|
||||
system prompt that defines how the KB behaves, the full default skill set,
|
||||
`LICENSE` and `VERSION`, the generic `README`/`MANUAL` docs, and the empty
|
||||
`wiki/` scaffold (routing table, overview, log, error book, entity/graph
|
||||
indexes). The result is a new, empty KB that behaves exactly like this one,
|
||||
ready for its first `raw/inbox/` drop and "Ingest."
|
||||
|
||||
This is a one-way copy from this repo's own template files into a
|
||||
different folder. It never reads or writes anything in this repo's `raw/`,
|
||||
`wiki/entities/`, `wiki/graph/edges.json`, or `outputs/` - those hold this
|
||||
The template it copies from is either this repo's own files or a fresh
|
||||
shallow clone of the canonical template repo pulled into a scratch folder
|
||||
(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*
|
||||
travel into a fresh KB. (For catching an *already-populated* KB up with
|
||||
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"
|
||||
- "bootstrap a wiki here" / "create a knowledge base with this schema"
|
||||
- "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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
`wiki/` templates) is always included, and so is the full reusable KB
|
||||
skill set - these operate purely on the `wiki/` structure (or, for
|
||||
`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
|
||||
concerned. As of this writing, that's every skill under this repo's
|
||||
`.agents/skills/`:
|
||||
concerned. As of this writing, that's every skill under the template
|
||||
source's `.agents/skills/`:
|
||||
|
||||
- `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-ingest`
|
||||
- `ckb-decide`
|
||||
- `ckb-retrieve`
|
||||
- `ckb-index-external`
|
||||
- `ckb-lint`
|
||||
- `ckb-sync-changes`
|
||||
- `ckb-project-summary`
|
||||
|
|
@ -79,7 +147,7 @@ concerned. As of this writing, that's every skill under this repo's
|
|||
- `ckb-export-starlight`
|
||||
- `ckb-onboard-me`
|
||||
- `ckb-teach-me`
|
||||
- `cbk-quiz`
|
||||
- `ckb-quiz`
|
||||
|
||||
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>`."
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -109,17 +177,19 @@ raw/archive/
|
|||
tmp/
|
||||
wiki/entities/
|
||||
wiki/graph/
|
||||
wiki/projects/
|
||||
wiki/decisions/
|
||||
workload/
|
||||
```
|
||||
|
||||
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
|
||||
own convention is an empty file named `.gitadd` in each directory (not
|
||||
`.gitkeep`) - match that convention exactly, so a new KB's directory
|
||||
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
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
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,
|
||||
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
|
||||
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
|
||||
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
|
||||
the template and stripping every reference to this project's actual
|
||||
content (Grant Thornton, Cloud Drift, specific entities, etc.) down to the
|
||||
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
|
||||
(Overview, Log, Error Book, Entities, Graph) and no entity rows, plus the
|
||||
"## 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
|
||||
repo's specific bullet list.
|
||||
- **`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
|
||||
content specific to this project - the whole point is an empty KB with the
|
||||
same shape.
|
||||
|
||||
### Step 8 - Write `.gitignore`
|
||||
### Step 9 - Write `.gitignore`
|
||||
|
||||
Copy this repo's actual current `.gitignore` verbatim rather than
|
||||
reconstructing it from memory - it uses a `<dir>/*` + `!<dir>/.gitadd`
|
||||
|
|
@ -196,6 +279,16 @@ tracked shell but have their real contents ignored:
|
|||
```
|
||||
libs/*
|
||||
!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/.gitadd
|
||||
outputs/starlight
|
||||
|
|
@ -204,8 +297,13 @@ outputs/teaching
|
|||
.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
|
||||
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
|
||||
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
|
||||
|
|
@ -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,
|
||||
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
|
||||
target's `.agents/skills/<name>/` unchanged (including each skill's own
|
||||
license footer, and any support files like
|
||||
`ckb-export-starlight/scripts/export_starlight.py`) - the full default set
|
||||
from Step 3, plus anything explicitly added. Then create `.claude/skills`
|
||||
`ckb-export-okf/scripts/export_okf.py`,
|
||||
`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
|
||||
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:
|
||||
- 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.
|
||||
- Whether `AGENTS.md`/`CLAUDE.md` were written or (per Step 2) skipped/merged.
|
||||
- Which skills were copied (the full default set, plus anything explicitly
|
||||
added).
|
||||
- The `VERSION` the new KB starts on, and what was decided for `LICENSE`'s
|
||||
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."
|
||||
|
||||
## Edge cases
|
||||
|
|
@ -246,6 +367,19 @@ Tell the user:
|
|||
- **User wants only *some* of the wiki template files** (e.g. just the
|
||||
directory structure, no `AGENTS.md`) - honor that; the steps above are
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`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
|
||||
|
|
@ -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`/
|
||||
`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
|
||||
|
||||
Verify every non-reserved `.md` file under `wiki/` (i.e. excluding
|
||||
`index.md` and `log.md`) has parseable YAML frontmatter with a non-empty
|
||||
`type` field. Flag violations first, and treat flagged pages as
|
||||
unreliable input for the checks below rather than guessing at their
|
||||
intended type/content.
|
||||
The script flags every non-reserved `.md` file under `wiki/` (i.e.
|
||||
excluding `index.md` and `log.md`) whose frontmatter is unparseable,
|
||||
absent, or missing a non-empty `type`. Treat flagged pages as unreliable
|
||||
input for the checks below rather than guessing at their intended
|
||||
type/content, and fix the frontmatter before acting on any later finding
|
||||
about the same page.
|
||||
|
||||
### 2 — Freshness check
|
||||
|
||||
Scan every page whose `last_updated` exceeds its `freshness_window_days`.
|
||||
Flag as stale; suggest the user confirm or update the content — don't
|
||||
silently rewrite stale content yourself.
|
||||
The script reports every page whose `last_updated` exceeds its
|
||||
`freshness_window_days`, and by how much. Suggest the user confirm or
|
||||
update the content — don't silently rewrite stale content yourself.
|
||||
|
||||
### 3 — Confidence decay
|
||||
|
||||
Reduce `confidence` on pages not reinforced by a new source since the last
|
||||
check. Pages that fall below 0.3 confidence get flagged for re-review.
|
||||
The script reports pages already below 0.3 `confidence` (and any
|
||||
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
|
||||
|
||||
Move `retention: low` pages older than 2× their `freshness_window_days`
|
||||
into `wiki/archived/`. Never delete — always move, and log the move (see
|
||||
Rule B in `CLAUDE.md`/`AGENTS.md`) with a note explaining why.
|
||||
The script reports `retention: low` pages older than 2x their
|
||||
`freshness_window_days` as archive candidates. Move them into
|
||||
`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.
|
||||
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.
|
||||
The script checks the *structure* of every `type: decision` page: a status
|
||||
from the vocabulary, a `decided_on` date where the status implies one, a
|
||||
`decided_by` (`unknown` counts — an omitted field doesn't), `affects` targets
|
||||
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
|
||||
|
||||
Find pages with no inbound links (`[[wikilinks]]` or
|
||||
`[markdown](path.md)` references from elsewhere in the wiki). Either add
|
||||
backlinks from relevant pages where an obvious connection exists, or move
|
||||
the orphan to `wiki/archived/` with a log note if no natural backlink
|
||||
exists.
|
||||
The script reports pages with no inbound links (`[[wikilinks]]` or
|
||||
`[markdown](path.md)` references from anywhere else in the tree). For each
|
||||
one, either add backlinks from relevant pages where an obvious connection
|
||||
exists, or move the orphan to `wiki/archived/` with a log note if no
|
||||
natural backlink does — that choice is the judgment the script leaves you.
|
||||
|
||||
### 7 — Graph consistency
|
||||
|
||||
Verify every edge in `wiki/graph/edges.json` points to an existing entity
|
||||
page. Remove or fix broken edges; note what was removed rather than
|
||||
silently dropping entries.
|
||||
The script reports every edge in `wiki/graph/edges.json` whose `from`/`to`
|
||||
does not resolve to an existing page (plus malformed edges and invalid
|
||||
JSON). Remove or fix them; note what was removed rather than silently
|
||||
dropping entries.
|
||||
|
||||
### 8 — Index/log consistency
|
||||
|
||||
Verify every subdirectory under `wiki/` that contains pages has an
|
||||
`index.md` listing all of them, and that no single change is recorded in
|
||||
both a subdirectory `log.md` and the root `wiki/log.md` (per the
|
||||
Recursive Index & Log Convention). Fix missing index entries and
|
||||
duplicate log entries directly.
|
||||
The script reports subdirectories holding pages but no `index.md`, index
|
||||
files that don't list a page sitting next to them, and any change recorded
|
||||
in both a subdirectory `log.md` and the root `wiki/log.md` (per the
|
||||
Recursive Index & Log Convention). Fix missing index entries and duplicate
|
||||
log entries directly — these are the safest auto-fixes on the list.
|
||||
|
||||
### 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
|
||||
|
||||
For each `libs/<name>/source.yaml`, verify it has a non-empty `connector`
|
||||
and `location` — report only, this file is never edited by any skill. If
|
||||
an `index:` block is present, verify it has a non-empty `store` and
|
||||
`location` too. If `refresh_interval_days` is present, verify it's a
|
||||
positive integer. Also flag (report only) a `libs/<name>/` that
|
||||
ambiguously has both real content files and a `source.yaml` — that's a
|
||||
configuration conflict for the user to resolve, not something to guess at.
|
||||
The script validates each `libs/<name>/source.yaml` (non-empty `connector`
|
||||
and `location`; non-empty `store` and `location` inside any `index:` block;
|
||||
a positive integer `refresh_interval_days`) and flags a `libs/<name>/` that
|
||||
ambiguously holds both real content files and a `source.yaml`. All of this
|
||||
is **report only** — `source.yaml` is never edited by any skill, and the
|
||||
ambiguous-content case is a configuration conflict for the user to resolve,
|
||||
not something to guess at.
|
||||
|
||||
Report any connector-backed source whose generated index is overdue:
|
||||
newest `last_updated` in `libs/<name>/` older than its
|
||||
`refresh_interval_days` (default 30). Report it the same way whether or
|
||||
not this user has write access — a read-only user can't fix it, but
|
||||
knowing which source to chase the admin about is the actionable part. Name
|
||||
how overdue it is rather than just "stale," since a source two days past a
|
||||
7-day interval is a different situation from one six months past a 30-day
|
||||
one. Never re-index here; that's `ckb-index-external`'s job, and suggesting
|
||||
it is as far as this check goes.
|
||||
It also reports any connector-backed source whose generated index is
|
||||
overdue — newest `last_updated` in `libs/<name>/` older than its
|
||||
`refresh_interval_days` (default 30) — with how overdue it is, since a
|
||||
source two days past a 7-day interval is a different situation from one six
|
||||
months past a 30-day one. Relay that the same way whether or not this user
|
||||
has write access: a read-only user can't fix it, but knowing which source to
|
||||
chase the admin about is the actionable part. Never re-index here; that's
|
||||
`ckb-index-external`'s job, and suggesting it is as far as this check goes.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
|
@ -172,11 +228,14 @@ fix or flag), skip this reminder — there's nothing to review or sync.
|
|||
## Edge cases
|
||||
|
||||
- **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
|
||||
invalid YAML) — flag it prominently in the conformance check and skip
|
||||
it in every later numbered check rather than letting a parse error
|
||||
crash or silently mis-handle downstream logic.
|
||||
invalid YAML) — the script reports it under conformance and skips it in
|
||||
the later checks rather than reasoning from a half-parsed page. Surface
|
||||
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
|
||||
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
|
||||
|
|
@ -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
|
||||
clearly newer/better corroborated) — report the conflict rather than
|
||||
guessing which one wins.
|
||||
- **Repeated run with nothing changed since the last lint** — should
|
||||
produce essentially the same clean report each time; don't invent
|
||||
variation just to seem active.
|
||||
- **Repeated run with nothing changed since the last lint** — produces the
|
||||
same report each time (pass `--today` to pin the date if you need a
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
607
.agents/skills/ckb-lint/scripts/lint_report.py
Normal file
607
.agents/skills/ckb-lint/scripts/lint_report.py
Normal 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())
|
||||
|
|
@ -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
|
||||
the rest of the cascade.
|
||||
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
|
||||
connector-backed lib this means its *generated* index (both the
|
||||
Documents and the Entities & Processes sections `ckb-index-external`
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -16,3 +16,5 @@ outputs/starlight
|
|||
outputs/okf
|
||||
outputs/teaching
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
19
AGENTS.md
19
AGENTS.md
|
|
@ -31,6 +31,7 @@ Maintain this root layout:
|
|||
│ ├── error-book.md
|
||||
│ ├── query-gaps.md
|
||||
│ ├── projects/
|
||||
│ ├── decisions/ # Numbered, append-only decision records
|
||||
│ ├── entities/
|
||||
│ └── graph/
|
||||
└── 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
|
||||
`ckb-upgrade`.
|
||||
|
||||
|
|
@ -100,14 +108,15 @@ file.
|
|||
|---|---|
|
||||
| Answer or research a KB question | `ckb-retrieve` |
|
||||
| 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` |
|
||||
| Health-check or repair wiki/index structure | `ckb-lint` |
|
||||
| Sync this repo with `origin` | `ckb-sync-changes` |
|
||||
| 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` |
|
||||
| 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:
|
||||
|
||||
|
|
@ -116,6 +125,10 @@ Short routing rules:
|
|||
ownership lookups, evidence packets, source verification, answer
|
||||
caveats, and query-gap capture.
|
||||
- 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
|
||||
index", use `ckb-index-external`.
|
||||
- For "Lint" or "health-check the wiki", use `ckb-lint`.
|
||||
|
|
|
|||
64
MANUAL.md
64
MANUAL.md
|
|
@ -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
|
||||
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
|
||||
`AGENTS.md` already), the agent will stop and ask before touching anything
|
||||
— 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
|
||||
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:
|
||||
|
||||
|
|
@ -229,8 +275,12 @@ This runs a health check across the whole wiki:
|
|||
that source, since it tells you who to chase
|
||||
- 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
|
||||
decide. Like Ingest, it finishes by reminding you to review and sync.
|
||||
The detection half runs as a read-only Python script
|
||||
(`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 —
|
||||
`.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
|
||||
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.
|
||||
`.agents/skills/cbk-quiz/SKILL.md`.
|
||||
`.agents/skills/ckb-quiz/SKILL.md`.
|
||||
|
||||
**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.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/`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
@ -621,10 +672,13 @@ graph stay in sync with what you changed.
|
|||
| Say... | What happens | Skill |
|
||||
|---|---|---|
|
||||
| "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` |
|
||||
| "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` |
|
||||
| "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` |
|
||||
| "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` |
|
||||
|
|
|
|||
65
MANUAL.pl.md
65
MANUAL.pl.md
|
|
@ -49,6 +49,15 @@ Nigdy nie kopiuje rzeczywistej zawartości tego projektu (żadnych encji,
|
|||
danych grafu, notatek). Otrzymujesz świeżą, pustą KB, gotową na pierwszy
|
||||
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
|
||||
`AGENTS.md`), agent zatrzyma się i zapyta, zanim czegokolwiek dotknie — nie
|
||||
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
|
||||
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ś:
|
||||
|
||||
|
|
@ -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ć
|
||||
- powtarzające się problemy systemowe trafiają do `wiki/error-book.md`
|
||||
|
||||
Naprawia samodzielnie to, co może zrobić bezpiecznie, a resztę zgłasza do
|
||||
twojej decyzji. Podobnie jak Ingest, na koniec przypomina o przejrzeniu i
|
||||
Połowa wykrywająca działa jako skrypt Python tylko-do-odczytu
|
||||
(`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` —
|
||||
`.agents/skills/ckb-lint/SKILL.md`.
|
||||
|
||||
|
|
@ -450,7 +501,7 @@ raportuje.
|
|||
Zostaniesz zapytany o liczbę pytań i format (otwarte / jednokrotnego
|
||||
wyboru), a następnie przejdziesz przez nie jedno po drugim, z natychmiastową
|
||||
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:
|
||||
|
||||
|
|
@ -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.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/`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
@ -654,10 +706,13 @@ zmieniłeś.
|
|||
| 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` |
|
||||
| „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` |
|
||||
| „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` |
|
||||
| „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` |
|
||||
| „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` |
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -33,6 +33,7 @@ worked examples for every use case — see [MANUAL.md](MANUAL.md)
|
|||
│ ├── error-book.md # Compilation errors & derived constraints
|
||||
│ ├── query-gaps.md # Failed or missing-answer questions for future ingest
|
||||
│ ├── 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
|
||||
│ └── graph/ # Edge lists and relationship data + own index.md
|
||||
└── 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
|
||||
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
|
||||
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
|
||||
|
|
@ -369,9 +389,13 @@ internal schema (`confidence`/`quality`/`retention`/`supersedes`/dual-linking)
|
|||
that OKF doesn't natively understand. Implemented as a Claude Code Skill —
|
||||
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
|
||||
actually invoked. `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.
|
||||
actually invoked. A deterministic Python script
|
||||
(`scripts/export_okf.py`) does the whole transform — frontmatter remapping,
|
||||
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)
|
||||
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/`,
|
||||
`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)
|
||||
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
|
||||
|
|
@ -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
|
||||
facts, runs them one at a time with immediate feedback and a running
|
||||
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)
|
||||
Ask to be taught the wiki ("teach me the wiki", "teach me about X", "run a
|
||||
|
|
|
|||
45
README.pl.md
45
README.pl.md
|
|
@ -32,6 +32,7 @@ znajdziesz w [MANUAL.pl.md](MANUAL.pl.md) ([English](MANUAL.md)).
|
|||
│ ├── overview.md # Mapa wysokiego poziomu
|
||||
│ ├── log.md # Główny dziennik zmian (rollup)
|
||||
│ ├── 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
|
||||
│ └── graph/ # Listy krawędzi i dane relacji + własny index.md
|
||||
└── 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
|
||||
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
|
||||
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
|
||||
|
|
@ -411,10 +432,14 @@ bogatszego wewnętrznego schematu
|
|||
którego OKF natywnie nie rozumie. Zaimplementowane jako Claude Code Skill —
|
||||
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
|
||||
kontekstu tylko wtedy, gdy jest faktycznie wywoływany. `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.
|
||||
kontekstu tylko wtedy, gdy jest faktycznie wywoływany. Cały transform —
|
||||
przemapowanie frontmatteru, przepisanie linków, regeneracja indeksów i
|
||||
dzienników oraz sprawdzenie zgodności z OKF na własnym wyjściu — wykonuje
|
||||
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)
|
||||
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
|
||||
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)
|
||||
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:
|
||||
|
|
@ -468,7 +503,7 @@ strony, generuje pytania otwarte lub jednokrotnego wyboru oparte na
|
|||
konkretnych faktach z wiki, przeprowadza je jedno po drugim z natychmiastową
|
||||
informacją zwrotną i bieżącym wynikiem, a na koniec podaje werdykt.
|
||||
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)
|
||||
Poproś o naukę z wiki („teach me the wiki", „naucz mnie o X", „przeprowadź
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.3.0
|
||||
1.6.0
|
||||
|
|
|
|||
0
wiki/decisions/.gitadd
Normal file
0
wiki/decisions/.gitadd
Normal file
25
wiki/decisions/index.md
Normal file
25
wiki/decisions/index.md
Normal 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
16
wiki/decisions/log.md
Normal 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.
|
||||
---
|
||||
|
|
@ -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.
|
||||
* 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.
|
||||
|
||||
*(No edges recorded yet — populated on the next ingest.)*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
kb_schema_version: "1.3"
|
||||
kb_schema_version: "1.4"
|
||||
---
|
||||
|
||||
# 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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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.
|
||||
|
||||
*(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.
|
||||
|
|
|
|||
26
wiki/log.md
26
wiki/log.md
|
|
@ -1,5 +1,31 @@
|
|||
# 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]
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
type: overview
|
||||
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
|
||||
|
|
@ -28,6 +28,7 @@ When the same entity exists in multiple layers, the local version wins.
|
|||
├── wiki/ # Local structured wiki (agent-managed)
|
||||
│ ├── query-gaps.md # Failed or missing-answer questions for demand-driven ingest
|
||||
│ ├── projects/ # Optional local query scopes
|
||||
│ ├── decisions/ # Numbered, append-only decision records
|
||||
│ ├── entities/ # Typed entity pages
|
||||
│ └── graph/ # Edge lists and relationship data
|
||||
└── workload/ # Summaries of discussions and decisions
|
||||
|
|
@ -36,13 +37,25 @@ When the same entity exists in multiple layers, the local version wins.
|
|||
## Page Frontmatter
|
||||
Every wiki page carries YAML frontmatter with a required `type` field, plus
|
||||
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.
|
||||
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
|
||||
`entities/`, `graph/`, and optional topic folders such as `projects/` each
|
||||
carry their own `index.md` so navigation stays lazy — read `wiki/index.md`
|
||||
`entities/`, `graph/`, `decisions/`, and optional topic folders such as
|
||||
`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
|
||||
relevant to the current task.
|
||||
|
||||
|
|
|
|||
44
workload/2026-09-01_summary.md
Normal file
44
workload/2026-09-01_summary.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue