okf 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.okf/capabilities/agent-skill.md +112 -0
  3. data/.okf/capabilities/bundles-manager.md +133 -0
  4. data/.okf/capabilities/graph-server.md +678 -0
  5. data/.okf/capabilities/index.md +26 -0
  6. data/.okf/capabilities/library-api.md +82 -0
  7. data/.okf/capabilities/linter.md +83 -0
  8. data/.okf/capabilities/read-views.md +228 -0
  9. data/.okf/capabilities/render.md +66 -0
  10. data/.okf/capabilities/search.md +295 -0
  11. data/.okf/capabilities/validator.md +60 -0
  12. data/.okf/cli.md +193 -0
  13. data/.okf/design/browser-tests.md +211 -0
  14. data/.okf/design/core-shell-split.md +73 -0
  15. data/.okf/design/index.md +17 -0
  16. data/.okf/design/integration-first.md +140 -0
  17. data/.okf/design/packaging.md +65 -0
  18. data/.okf/design/ruby-floor.md +53 -0
  19. data/.okf/design/runtime-dependencies.md +82 -0
  20. data/.okf/design/search-engines.md +154 -0
  21. data/.okf/design/server-trust-boundary.md +139 -0
  22. data/.okf/index.md +40 -0
  23. data/.okf/log.md +662 -0
  24. data/.okf/model/bundle.md +47 -0
  25. data/.okf/model/concept.md +75 -0
  26. data/.okf/model/graph.md +59 -0
  27. data/.okf/model/index.md +9 -0
  28. data/.okf/model/skeleton.md +76 -0
  29. data/.okf/overview.md +87 -0
  30. data/.okf/registry.md +265 -0
  31. data/.okf/structure/format-layer.md +59 -0
  32. data/.okf/structure/index.md +22 -0
  33. data/.okf/structure/search.md +53 -0
  34. data/.okf/structure/the-analysers.md +60 -0
  35. data/.okf/structure/the-cli.md +99 -0
  36. data/.okf/structure/the-disk-shell.md +67 -0
  37. data/.okf/structure/the-model.md +81 -0
  38. data/.okf/structure/the-server.md +74 -0
  39. data/.okf/structure/the-skill.md +52 -0
  40. data/.okf/testing/adding-a-verb.md +76 -0
  41. data/.okf/testing/index.md +12 -0
  42. data/.okf/testing/the-harness.md +45 -0
  43. data/CHANGELOG.md +83 -16
  44. data/README.md +205 -21
  45. data/lib/okf/cli.rb +20 -2
  46. data/lib/okf/skill/SKILL.md +15 -7
  47. data/lib/okf/skill/playbooks/maintain.md +3 -1
  48. data/lib/okf/skill/playbooks/produce.md +4 -2
  49. data/lib/okf/skill/reference/authoring.md +23 -26
  50. data/lib/okf/skill/reference/cli/checks.md +171 -0
  51. data/lib/okf/skill/reference/cli/graph.md +49 -0
  52. data/lib/okf/skill/reference/cli/map.md +98 -0
  53. data/lib/okf/skill/reference/cli/registry.md +70 -0
  54. data/lib/okf/skill/reference/cli/search.md +130 -0
  55. data/lib/okf/skill/reference/cli/serve.md +83 -0
  56. data/lib/okf/skill/reference/cli/views.md +59 -0
  57. data/lib/okf/skill/reference/cli.md +33 -603
  58. data/lib/okf/skill/reference/spec-map.md +32 -0
  59. data/lib/okf/version.rb +1 -1
  60. metadata +54 -5
@@ -0,0 +1,47 @@
1
+ ---
2
+ type: Component
3
+ title: OKF::Bundle
4
+ description: The pure in-memory collection of concepts that validate, lint, and graph run over.
5
+ resource: gems/okf/lib/okf/bundle.rb
6
+ tags: [pure]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-08-13T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/bundle.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/bundle.rb
13
+ ---
14
+
15
+ # Overview
16
+
17
+ `OKF::Bundle` is a set of [concepts](concept.md) held together in memory, with no
18
+ disk involved. It is the object the three judging capabilities operate on:
19
+ `#validate`, `#lint`, and `#graph` each hand the bundle to a dedicated pure
20
+ class and return a result. A bundle also carries the reserved files
21
+ (`index.md`, `log.md`) and — importantly — an `unparseable` list.
22
+
23
+ # Best-effort by construction
24
+
25
+ When a bundle is built from disk, files the reader cannot use do not vanish and do
26
+ not abort the build: they are collected in `bundle.unparseable` — frontmatter that
27
+ would not parse, and files that would not open, since a locked file is one file's
28
+ problem and not the bundle's. That is what
29
+ lets [graph](graph.md), the [server](../capabilities/graph-server.md), and the
30
+ [read views](../capabilities/read-views.md) render everything that *is* valid
31
+ while the [CLI](../cli.md) notes the skips on stderr — §11's best-effort posture,
32
+ made structural.
33
+
34
+ # Build it from data, not only from files
35
+
36
+ Because the bundle is pure, an embedding application can construct concepts
37
+ straight from its own records — no Markdown round-trip — and still get validate,
38
+ lint, and graph for free. This is the surface the
39
+ [library API](../capabilities/library-api.md) exposes to, say, a Rails store that
40
+ already holds knowledge as rows. It also feeds the shared `#catalog`, the data
41
+ behind every read view — and the rollups beside it, each kept in one home
42
+ after a hand copy diverged: `#stats` (whose `by_dir` reads the directory
43
+ index, so an empty directory reports its honest zero), `#tag_groups` (the
44
+ vocabulary-curation cut, within-group counts beside cross-set totals), and
45
+ `Bundle::References` (the §6.3 inventory — the one model that sees a bundle's
46
+ non-markdown files, fed a manifest by the shell). The CLI verbs and the MCP
47
+ tools consume the same three.
@@ -0,0 +1,75 @@
1
+ ---
2
+ type: Component
3
+ title: OKF::Concept
4
+ description: The pure in-memory model of a single OKF file — frontmatter, body, and a stable id.
5
+ resource: gems/okf/lib/okf/concept.rb
6
+ tags: [pure]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-08-14T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/concept.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/concept.rb
13
+ ---
14
+
15
+ # Overview
16
+
17
+ `OKF::Concept` is the atomic node: a `path`, a parsed
18
+ `frontmatter` (`@okf-eco format/frontmatter`) hash, and a Markdown `body`. It is
19
+ [pure](../design/core-shell-split.md) — it holds no file handle and does no I/O.
20
+ The on-disk counterpart is `OKF::Concept::File`, part of the
21
+ [library API](../capabilities/library-api.md).
22
+
23
+ # The id is the concept's identity
24
+
25
+ `#id` is the concept's **stable identifier** across the whole system — the graph
26
+ node key, the link target, and the thing you name a concept for. By default it is
27
+ the `path` minus `.md` (e.g. `model/graph.md` → `model/graph`), which is why you
28
+ name a file for what it *is*, not where it sits. A frontmatter `id`, when set,
29
+ pins the identity explicitly — the path-derived name is the fallback, not the only
30
+ source — so a concept can keep its id across a move.
31
+
32
+ The override is this gem's **extension, not spec**: §2 defines the concept id
33
+ as the path minus `.md`, full stop. Pinning one puts the concept in two worlds
34
+ on purpose — the identity views ([catalog, hubs, `--dir`,
35
+ search](../capabilities/read-views.md)) follow the id, because the edges do,
36
+ while the physical views (`index`, `dirs`, stats' `by_dir`) keep the file where
37
+ it lives, because an index is a physical listing. An integration test holds a
38
+ concept whose id leaves its directory and asserts both worlds at once, so the
39
+ split stays a decision; the authoring advice is the default's — rename the
40
+ file, not the id.
41
+
42
+ # What it derives from its own content
43
+
44
+ The concept parses its body on demand into the structural facts the rest of the
45
+ gem consumes:
46
+
47
+ - `#type`, `#title`, `#description`, `#resource`, `#tags` — typed reads over
48
+ the frontmatter;
49
+ - the §5 families, each carrying §13.1's fallbacks inline: `#generated` /
50
+ `#generated_at` / `#generated_by` (a legacy `timestamp` lifts into `at`,
51
+ per-key, with no actor ever invented), `#sources` (the native list, or the
52
+ legacy `# Citations` (`@okf-eco format/citations`) body list whenever the native
53
+ value yields zero mappings), `#verified` (a bare mapping reads as a
54
+ one-element list; degenerate shapes fold to unverified), `#trust_tier` /
55
+ `#trust` (derived per §5.3, never stored), `#status`/`#declared_status`
56
+ (absent reads stable; both keep the producer's spelling, because every
57
+ surface that *displays* a status prints what was written — the fold belongs
58
+ to `.effective_status`, which is what narrowing compares through), `#stale_after_date`/`#stale_on?(today)` (§5.5 — stale
59
+ on the day itself; the clock is always an argument, never read);
60
+ - the §10 contract of an Attested Computation: `#runtime`, `#parameters`,
61
+ `#computation`, `#executor`, `#attester`, `#attested_computation?`;
62
+ - `#usage_window` — validated for shape, deliberately consumed by nothing: no
63
+ gem surface computes over usage counts, so an effective-window resolver
64
+ would be speculative;
65
+ - `#declared_generated?`, `#legacy_timestamp?`, `#legacy_citations?` — raw-key
66
+ detection for lint and the surfaces; detection never influences reading;
67
+ - `#links` — every raw cross-link (`@okf-eco format/cross-links`) target in the body, in order; the bundle-relative ones become graph edges;
68
+ - `#external_links` — the subset of those that are URLs or `mailto:` (not edges);
69
+ - `#to_markdown` — the inverse of the frontmatter parser (`#citations` is gone,
70
+ subsumed by `#sources`);
71
+ - `#lint` — the concept-scoped [lint](../capabilities/linter.md) checks in isolation.
72
+
73
+ A concept never decides conformance alone; a [bundle](bundle.md) does, because
74
+ some checks (duplicate titles, missing link targets) are only meaningful across
75
+ the set.
@@ -0,0 +1,59 @@
1
+ ---
2
+ type: Component
3
+ title: OKF::Bundle::Graph
4
+ description: The in-memory knowledge graph — concepts as nodes, cross-links as directed edges, with type and tag indexes.
5
+ resource: gems/okf/lib/okf/bundle/graph.rb
6
+ tags: [graph, pure]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-08-13T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/bundle/graph.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/bundle/graph.rb
13
+ ---
14
+
15
+ # Overview
16
+
17
+ `OKF::Bundle::Graph` turns a [bundle](bundle.md) into nodes and edges:
18
+ [concepts](concept.md) become nodes keyed by id, and bundle-relative
19
+ cross-links (`@okf-eco format/cross-links`) become directed edges. It is pure — it
20
+ carries no presentation concerns; sizing and colour belong to a renderer like
21
+ the [graph server](../capabilities/graph-server.md).
22
+
23
+ # Fidelity is a build option
24
+
25
+ The same graph ships at three weights, so a client downloads only what it needs
26
+ and fetches the rest on demand:
27
+
28
+ | Build | Node payload |
29
+ |-------|--------------|
30
+ | default (`body: true`) | id, type, title, description, tags, **body** |
31
+ | `body: false` | everything but the body |
32
+ | `minimal: true` | just id and title — the leanest payload to draw |
33
+
34
+ # Indexes come free at every weight
35
+
36
+ Regardless of node fidelity, the graph exposes two inverted indexes computed from
37
+ every concept:
38
+
39
+ - `type_index` — `{ type => [id, …] }`, so even a minimal client can colour nodes
40
+ by [`type`](concept.md). A concept whose type is missing *or blank* indexes
41
+ under `Untyped`: §11 condition 2 rejects the two identically (the
42
+ [validator](../capabilities/validator.md) asks `OKF.blank?`, not `empty?`), so
43
+ sorting them into different buckets would give `type: " "` a row of its own,
44
+ labelled with spaces;
45
+ - `tag_index` — `{ tag => [id, …] }`, so it can filter by tag.
46
+
47
+ Those indexes, plus `unlinked_ids` (degree-0 nodes), are what the
48
+ [read views](../capabilities/read-views.md) — `types`, `tags`, `stats`, `loose`
49
+ — are built from, and what their `--type`/`--dir`/`--tag` filters match against.
50
+
51
+ # Reduced to a skeleton
52
+
53
+ Where this graph is the full wiring, the [skeleton](skeleton.md) is the same
54
+ graph made holdable: concepts collapsed into their directory, the links between
55
+ two directories collapsed into one weighted arc, and every link tagged with the
56
+ cut it survives. It is the reduction [`graph --traffic`](../capabilities/read-views.md)
57
+ reads for cohesion and the [graph page](../capabilities/graph-server.md) lays a
58
+ large bundle out on — built from this graph's own nodes and edges, and just as
59
+ pure.
@@ -0,0 +1,9 @@
1
+ # The model
2
+
3
+ The pure, in-memory data structures the gem builds a bundle out of — no disk, no
4
+ stdio. Everything else reads or renders these.
5
+
6
+ * [Concept](concept.md) - one file's worth of knowledge: frontmatter plus body, with a stable id.
7
+ * [Bundle](bundle.md) - a collection of concepts you validate, lint, and graph.
8
+ * [Graph](graph.md) - concepts as nodes, cross-links as edges, plus type and tag indexes.
9
+ * [Skeleton](skeleton.md) - the graph reduced: directories, the weighted arcs between them, and each link with the cut it survives.
@@ -0,0 +1,76 @@
1
+ ---
2
+ type: Component
3
+ title: OKF::Bundle::Skeleton
4
+ description: The graph reduced to what a reader can hold — directories, the weighted arcs between them, and each link with the cut it survives.
5
+ resource: gems/okf/lib/okf/bundle/skeleton.rb
6
+ tags: [graph, pure]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-07-23T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/bundle/skeleton.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/bundle/skeleton.rb
13
+ - title: gems/okf/lib/okf/cli/graph.rb
14
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/cli/graph.rb
15
+ ---
16
+
17
+ # Overview
18
+
19
+ `OKF::Bundle::Skeleton` is the [graph](graph.md) reduced to what a reader can
20
+ hold in their head. It is built from a graph's nodes and edges, does no I/O, and
21
+ decides nothing about how any of it is drawn — the same purity the graph keeps,
22
+ one level of abstraction up. Two consumers read it: [`graph
23
+ --traffic`](../capabilities/read-views.md) prints its directory reduction, and
24
+ the [graph page](../capabilities/graph-server.md)'s link layer lays a large
25
+ bundle out on the backbone it names.
26
+
27
+ A dense bundle is not dense the way a hub-and-spoke picture is. Measured on a
28
+ 47-concept bundle with 227 links, the top hub takes 13 inbound and the median 4 —
29
+ no 80/20 to exploit, so dropping two-thirds of the *concepts* still leaves 53
30
+ edges. The density lives **between directories**: 173 of those 227 links (76%)
31
+ cross a directory boundary. That is the reduction worth drawing, and it is why
32
+ this class exists at all.
33
+
34
+ # The two things it produces
35
+
36
+ - **`dirs` + `arcs`** — the reduction as counts: one row per directory, one
37
+ weighted arc per ordered pair of directories. This is what `graph --traffic`
38
+ prints, with **cohesion** (a directory's internal share of its own traffic)
39
+ derived from them. Directories come off the concept *id* (`OKF.dir_of`), not
40
+ the file path, so this agrees with the graph's own `catalog` and `--dir`
41
+ filter — the side that follows the id, because the edges do.
42
+ - **`edges`, each with its `keep_at`** — the smallest cut at which a link
43
+ survives. Nothing prints these; they are what lets the graph page lay a large
44
+ bundle out on its strongest links first, inlined into the page as `EDGE_CUT`
45
+ because the layout needs them before any `fetch()` could answer.
46
+
47
+ Both are emitted **unthresholded**, and `#suggested_cut` *names* where to cut
48
+ rather than cutting — so a caller narrows the picture without this class ever
49
+ having to know what a picture is.
50
+
51
+ # The cut is fitted, not fixed
52
+
53
+ A fixed arc cut cannot serve both ends of the size range: measured at weight 3
54
+ across ten bundles, it left **2 arcs on one and 136 on another** — too tight to
55
+ be a picture at one end, no reduction at all at the other. What stays roughly
56
+ constant as a bundle grows is not the arc count but the arcs *per box*, since a
57
+ node-link diagram reads at about one to two edges per node regardless of size. So
58
+ `#suggested_cut` targets 1.5 arcs per directory and reports the weight that
59
+ delivers it, floored at 8 for the small end. Cohesion is computed over **every**
60
+ arc regardless of the cut, so narrowing the drawn picture never moves the
61
+ evidence under it.
62
+
63
+ # The spine is a sparsifier, not a sample
64
+
65
+ The `keep_at === 0` set is the **spine**: each concept's single most-connected
66
+ neighbour. It is chosen, not sampled, and the property that earns it the name is
67
+ that it **touches every linked concept**, so a layout run over it alone strands
68
+ nothing and the arrangement is a real one rather than a sketch to be redone.
69
+
70
+ It is the local-degree sparsifier (Lindner et al.) with a union rule — an edge
71
+ survives if *either* endpoint kept it — which is what stops it from stranding the
72
+ quiet half of a bundle the way a global "drop the weakest edges" rule does. It is
73
+ **not** the disparity filter, the usual name in this territory: that reads an
74
+ edge's weight against its endpoint's total, and every link here weighs exactly 1,
75
+ which makes every proportion identical and the filter a coin toss. Weighted-graph
76
+ tools do not transfer to an unweighted graph just because both are graphs.
data/.okf/overview.md ADDED
@@ -0,0 +1,87 @@
1
+ ---
2
+ type: Overview
3
+ title: okf at a glance
4
+ description: A light Ruby gem that reads, searches, validates, lints, and serves Open Knowledge Format v0.2 bundles.
5
+ tags: [okf, gem, diagram]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-13T12:00:00Z
9
+ sources:
10
+ - title: README.md
11
+ resource: https://github.com/serradura/okf/blob/main/README.md
12
+ - title: AGENTS.md
13
+ resource: https://github.com/serradura/okf/blob/main/AGENTS.md
14
+ ---
15
+
16
+ # Overview
17
+
18
+ **okf** — the gem on RubyGems — operates on OKF v0.2 (`@okf-eco format/okf-format`)
19
+ bundles: directories of Markdown files with YAML frontmatter that humans and
20
+ agents both read from one source. It does not define new knowledge storage; it
21
+ gives you leverage over knowledge that already lives as Markdown.
22
+
23
+ ```mermaid
24
+ flowchart LR
25
+ skill["companion<br/>agent skill"] -. authors/curate .-> bundle[("OKF v0.2 bundle<br/>Markdown + YAML")]
26
+ bundle --> model["pure model<br/>Concept · Bundle · Graph"]
27
+ skill -. execute .-> cli
28
+ subgraph cli ["okf CLI"]
29
+ validate["validate — legal? §11"]
30
+ lint["lint — well-curated?"]
31
+ search["search — which concept?"]
32
+ server["server — explore"]
33
+ end
34
+ model --> cli
35
+ model --> library["library API<br/>embed in Ruby"]
36
+ ```
37
+
38
+ Over such a bundle the gem gives you seven capabilities behind one
39
+ [command-line tool](cli.md):
40
+
41
+ | Capability | What it answers | Verb |
42
+ | -------------------------------------------------------- | --------------------------------- | ---------------- |
43
+ | [Companion agent skill](capabilities/agent-skill.md) | Can an agent author it? | `skill` |
44
+ | [Conformance validator](capabilities/validator.md) | Is this a legal OKF bundle? (§11) | `validate` |
45
+ | [Curation linter](capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
46
+ | [Ranked text search](capabilities/search.md) | Which concept covers X? | `search` |
47
+ | [Interactive graph server](capabilities/graph-server.md) | Can I explore it visually? | `server` |
48
+ | [Static render](capabilities/render.md) | Can I ship a serverless snapshot? | `render` |
49
+ | [Library API](capabilities/library-api.md) | Can my Ruby program use it? | (in-process) |
50
+
51
+ Beside the gem's seven, the sibling surfaces. The
52
+ MCP server (`@okf-mcp design/the-tool-set`) (`okf-mcp`) projects the same kernel
53
+ onto the Model Context Protocol so any MCP-capable agent host reads these
54
+ bundles without a terminal — ten read-only tools, concepts as resources a host
55
+ can attach on its own, and the [skill's](capabilities/agent-skill.md) playbooks
56
+ as prompts. The enforcement layer (`@okf-eco gems/okf-pro`) (`okf-pro`)
57
+ goes the other way and is the only surface here that **writes**: it generates an
58
+ agent's knowledge repository — bundle, hooks, pre-commit, CI, skill — and then
59
+ holds it to a few invariants at all three doors, under a contract where a gate
60
+ that cannot check refuses rather than shrugs.
61
+
62
+ Alongside those, a family of [read views](capabilities/read-views.md) —
63
+ `index`, `catalog`, `files`, `types`, `tags`, `stats`, `graph` — print the bundle at a
64
+ glance so an agent reads it without a browser.
65
+
66
+ Knowledge rarely lives in one bundle, so `okf server` hosts one, several, or every
67
+ bundle in a per-user [registry](registry.md) — one hub, one switcher, no per-repo
68
+ server to remember.
69
+
70
+ # The two ideas it inherits from the format
71
+
72
+ - **Dual audience.** Every file serves a human skimming it _and_ an agent
73
+ extracting from it, so bodies are structural Markdown and
74
+ links (`@okf-eco format/cross-links`) are plain Markdown links — both readers already
75
+ understand them.
76
+ - **The graph is emergent.** Files are nodes, Markdown links are edges. You never
77
+ declare a graph; the gem [builds one](model/graph.md) from how concepts link.
78
+
79
+ # Design ethos
80
+
81
+ The gem is deliberately light so it runs on the Ruby an OS already ships. That
82
+ ethos is not incidental — it is enforced by [hard constraints](design/):
83
+ a [Ruby 2.4 floor](design/ruby-floor.md), exactly
84
+ [three runtime dependencies](design/runtime-dependencies.md), and a
85
+ [core/shell split](design/core-shell-split.md) that keeps all logic pure and
86
+ testable without disk. Everything else — no ActiveSupport, no build step, no
87
+ JavaScript toolchain — follows from those.
data/.okf/registry.md ADDED
@@ -0,0 +1,265 @@
1
+ ---
2
+ type: Component
3
+ title: The bundle registry
4
+ description: An ordered list of bundle references persisted as JSON — global under $OKF_HOME, or project-local via `okf registry init` and discovered from the working directory — the kernel behind a bare `okf server`.
5
+ resource: gems/okf/lib/okf/registry.rb
6
+ tags: [cli, shell, registry]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-07-24T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/registry.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/registry.rb
13
+ ---
14
+
15
+ # Overview
16
+
17
+ `OKF::Registry` is the gem's only piece of *durable user state*: an ordered list
18
+ of bundle references, so `okf registry set` today and a bare `okf server`
19
+ tomorrow share one list. It is a plain JSON file — `$OKF_HOME/registry.json`,
20
+ `$OKF_HOME` defaulting to `~/.okf` — and that is a design choice, not a stopgap: a
21
+ database would break the [two-dependency rule](design/runtime-dependencies.md),
22
+ and the file is per-user, hand-editable, and greppable. It is part of the
23
+ [shell](design/core-shell-split.md); it reads and writes a file. That is the
24
+ *global* registry; a project can keep its own, discovered from the working
25
+ directory — see [Global by default, project-local by discovery](#global-by-default-project-local-by-discovery).
26
+
27
+ The registry stores *references*, never content. It holds a path, a slug, and a
28
+ title — the bundles themselves stay where they are on disk, owned by the repos
29
+ they document. Nothing is copied, so nothing can go stale except the path itself.
30
+
31
+ # Slugs: implicit is forgiving, explicit is strict
32
+
33
+ A slug is the bundle's mount key (`/b/<slug>/`) and its name in the
34
+ [switcher](capabilities/graph-server.md). Where it comes from decides what a
35
+ collision means:
36
+
37
+ | Source | On collision | Why |
38
+ |--------|--------------|-----|
39
+ | the directory basename (`registry set ./docs`) | silently suffixed — `docs-2`, `docs-3` | you never asked for a name; the gem picks a free one |
40
+ | an explicit `--as SLUG`, or `registry rename` | raises | you *did* ask for that name, so quietly serving a different one is a lie |
41
+
42
+ That asymmetry is the whole rule: **the gem may invent a name, but it may never
43
+ substitute one you chose.** Both paths run the same normalization the ephemeral
44
+ (unregistered) bundles use, so a directory mounts under the same slug whether it
45
+ was registered or passed straight to `okf server` — with exactly one exception,
46
+ the reserved `all/` below, which the registry alone has a reason to rename.
47
+
48
+ The rule extends cleanly to a name the grammar has already spoken for. `@all`
49
+ means *every registered bundle* to [`search`](capabilities/search.md), so no one
50
+ bundle may answer to `all` — it is reserved. A directory named `all/` therefore
51
+ registers as `all-2` (the basename was only a guess, so a suffix is right), while
52
+ `--as all` is refused (the ask was deliberate, so substituting `all-2` would be
53
+ the lie).
54
+
55
+ The reservation is the *registry's*, not the slug helper's, and that boundary is
56
+ load-bearing in both directions. Inward, all three ways a slug enters this list
57
+ are covered — minting, an explicit ask, and **reading the file**. The third is the
58
+ one that cannot refuse. `all` reaches the file two ways nothing can take back: a
59
+ release from before the name was reserved wrote it (a directory named `all/`
60
+ slugged exactly that), or a hand typed it into the file the format invites you to
61
+ edit. So the read *mints around it* — the entry lists, mounts, and answers to
62
+ `all-2`, and the next write persists the name.
63
+
64
+ **The read normalizes for the same reason**, and it is the same bug one step
65
+ wider: two of the three ways in normalized and the third did not. A hand-typed
66
+ `"slug": "My Docs"` listed perfectly well while `@my-docs` missed it — and so did
67
+ `rename` and `default`, which look an entry up through the very normalization the
68
+ read had skipped. The two verbs that could repair the entry were the two that
69
+ could not see it. A slug registration would have handed back untouched is left
70
+ alone (including one already suffixed, so fixing a sick entry never renames a
71
+ healthy one); everything else is minted around what the other entries hold.
72
+ <!-- rule:okf-registry-read-normalizes -->
73
+
74
+ That asymmetry was also the [graph server](capabilities/graph-server.md)'s XSS
75
+ trigger: slugs reach the bundle switcher's HTML, and the only way one could carry
76
+ a quote was to arrive un-normalized through this read. Normalizing closes it at
77
+ the source — the escape is hardened too, because a page whose safety depends on a
78
+ guarantee three layers away is not one you can reason about locally.
79
+
80
+ Refusing the file was the first answer here, and it was worth the correction it
81
+ took. A name the grammar has taken makes **one entry** unnameable; rejecting the
82
+ registry makes **every** entry unreachable — and takes `del` and `rename`, the two
83
+ verbs that could fix it, down on the very read they need to survive, leaving
84
+ hand-editing JSON as the only way out. A guard whose failure mode is worse than
85
+ what it guards against is not a guard. Minting is also simply the rule already
86
+ stated above, read one line further: the gem may invent a name, and here the name
87
+ on disk cannot be used, so inventing one is the only move that is not a lie.
88
+ <!-- rule:okf-registry-reserved-mint -->
89
+
90
+ Outward, it stops there: an ephemeral `okf server ./all` has no
91
+ registry and no refs, so there is no name to protect, and it mounts at `/b/all/` —
92
+ suffixing it would invent a `/b/all-2/` whose `/b/all/` does not exist. `all/` is
93
+ therefore the one directory whose registered slug and ephemeral slug differ, and
94
+ they differ because only one of the two worlds has a grammar that spells `all`.
95
+
96
+ The rule cuts one layer deeper, at the empty string. Minting a slug from a
97
+ basename must *produce* something, so `slugify` falls back to a placeholder when
98
+ nothing survives normalization. Looking one up must not: a lookup that inherits
99
+ that fallback makes `@***` resolve to whatever bundle happens to be slugged
100
+ `bundle` — the gem substituting a name you never chose, which is the one thing
101
+ the rule forbids. So `normalize` (no fallback) backs every lookup and every
102
+ explicit ask, and `slugify` (placeholder) backs only basename minting.
103
+
104
+ # The default is a position, not a stored name
105
+
106
+ The first entry still on disk is the bundle a bare `okf server` opens at `/`, and
107
+ `registry default <slug>` moves that entry to the front. That is the whole
108
+ feature.
109
+
110
+ "Still on disk" is the one qualifier position needs, and it is not a fallback in
111
+ disguise. The hub drops a bundle whose directory has vanished rather than serving
112
+ a hole, so a default that ignored the gap would put `registry list`'s `*` on a
113
+ bundle `/` never opens — the star names what `/` opens, so it has to skip what `/`
114
+ skips. The rule stays derivable from the file plus the disk, with nothing stored
115
+ and nothing to reconcile. Its mirror is that `registry default <slug>` *refuses* a
116
+ vanished directory, exactly as `registry set` refuses to register one: both are
117
+ explicit asks, and a move the default would then skip would answer with a slug the
118
+ user did not type.
119
+
120
+ The alternative — storing the chosen slug — looks simpler and is not. A stored
121
+ slug is a *foreign key* into the same list it lives in, and a foreign key demands
122
+ referential integrity from every operation that touches the list: carry it
123
+ through a `rename`, re-point it when `add --as` renames in place, clear it on a
124
+ `remove`, and fall back when it dangles anyway. Four obligations, each a place to
125
+ forget. Position owes nothing: a rename touches the name and leaves the row where
126
+ it is, a `del` lets the next row become first, and a default that is not in the
127
+ list is *unrepresentable* rather than merely handled. The registry was already
128
+ documented as ordered, so this is state it kept for free.
129
+
130
+ What it costs is a file that visibly reorders, which is why `registry default`
131
+ says so in its own help — the JSON is meant to be read and hand-edited, and a
132
+ reordering write should never be a surprise.
133
+
134
+ Identity is the **path**, not the slug: re-registering a directory already in the
135
+ registry refreshes its title in place instead of adding a twin.
136
+
137
+ # It names bundles for the whole CLI, not just the server
138
+
139
+ The registry began as the server's boot list and grew into the
140
+ [CLI](cli.md)'s name-resolution layer: wherever a verb takes a `<dir>`,
141
+ `@slug` resolves through it and bare `@` picks the default — the same slugs the
142
+ hub mounts at `/b/<slug>/`, so the name you click is the name you type. That is
143
+ what turns registering from "tell the server" into "give this bundle a name":
144
+ [`search`](capabilities/search.md) crosses several of them in one query, and no
145
+ verb needs a path once the bundle has a slug.
146
+
147
+ It is also no longer terminal-only. The graph page's ⚙ Bundles panel drives
148
+ `default`, `rename` and `remove` from a browser, through
149
+ [this class and its messages](capabilities/bundles-manager.md) rather than around
150
+ them. `add` stays terminal-only, because a browser cannot hand over a filesystem
151
+ path. The file stays the record: every write goes through here, and the hub
152
+ re-reads it per request rather than trusting a snapshot, so an `okf registry
153
+ rename` in another terminal shows on a refresh.
154
+
155
+ # Groups: a named set of bundles
156
+
157
+ A **group** is a slug that names not one bundle but a *list* of members — bundle
158
+ or group slugs, so groups nest — and resolves, recursively and path-deduped, to
159
+ the bundle leaves underneath. It is the durable form of typing `@a @b @c`: once
160
+ several bundles earn a name together (`okf registry group backend @orders
161
+ @billing`), `@backend` stands in for the set. `group`/`ungroup` add and remove
162
+ members; emptying a group deletes it, since an empty set resolves to nothing.
163
+
164
+ Groups live in **their own list** (`{ bundles: […], groups: […] }`), not among
165
+ the entries — a deliberate separation. The first-is-default rule and every
166
+ `File.directory?` guard assume an entry has a path, and a group has none;
167
+ threading a nil path through all of them to host a pathless member would be the
168
+ foreign-key tax the default rule already refused. A separate list leaves the
169
+ bundle invariants untouched and makes a group exactly what it is: a view over
170
+ them.
171
+
172
+ **One namespace, two kinds.** A slug names a bundle *or* a group, never both, so
173
+ `@backend` is unambiguous — the collision check that already spanned entries and
174
+ the reserved `all` now spans groups too, in both directions (`registry set --as
175
+ backend` is refused while a group holds it, and vice versa). And because a member
176
+ list stores slugs, the two lifecycle verbs keep those references live: `rename`
177
+ **cascades** the new name across every group that named the slug, and `del`
178
+ **cascade-drops** it (a group emptied that way is deleted). Skipping either would
179
+ orphan a member silently — the same drift the path-not-slug identity rule avoids
180
+ for the default.
181
+
182
+ **Only a set-taking verb consumes one.** [`search`](capabilities/search.md) and
183
+ [`server`](capabilities/graph-server.md) are the two verbs that already take
184
+ several bundles; a group feeds exactly them (`okf search @backend …` merges the
185
+ members into one ranking, `okf server @backend` mounts each). Every single-bundle
186
+ verb refuses a `@group` with exit 2 — the same second-bundle rule that stops
187
+ `okf lint a b` from linting `a` and ignoring `b`, because a group resolving to
188
+ three bundles is that ambiguity by another spelling. `@all` is unchanged: it
189
+ still names every registered *bundle*, and a group is a named subset of what it
190
+ already covers. A cycle is refused at write time and guarded again at resolution,
191
+ since the file is hand-editable.
192
+ <!-- rule:okf-registry-groups-cascade -->
193
+
194
+ # It tolerates a world that changes underneath it
195
+
196
+ A registry entry is a bet that a directory still exists, and the registry never
197
+ prunes on its own — deleting a bundle from disk must not silently rewrite a list
198
+ the user curated. Instead `listing` marks the entry `missing`, so
199
+ [`registry list`](cli.md) shows the gap and the user decides. Reads are equally
200
+ forgiving: the original bare-array file shape still parses, while a *corrupt*
201
+ file raises with the fix — "fix or delete the file" —
202
+ rather than starting from an empty list and silently dropping every bundle.
203
+
204
+ That message invites a hand-edit, which is why the shape is checked and not just
205
+ the syntax: valid JSON is not a valid registry, and an entry missing its `path`
206
+ must fail here, as a usage error naming the file, rather than surviving to crash
207
+ a `File.directory?` three frames away. The [CLI](cli.md) does its half by
208
+ loading through a guard, so a broken file reaches the user as an error from
209
+ whatever verb they ran — not a backtrace from a verb that never rescued one.
210
+
211
+ Writes go to a temp file and are promoted with `rename`, the same atomic
212
+ promotion the [bundle writer](capabilities/library-api.md) uses, so a booting
213
+ server never reads a torn file. Two racing writers stay last-writer-wins: this is
214
+ a per-user file, and locking would buy nothing worth the complexity.
215
+
216
+ # Global by default, project-local by discovery
217
+
218
+ The registry has two homes, and which one answers is decided by *where you stand*,
219
+ not by a flag. The global one is the `$OKF_HOME/registry.json` above — one per
220
+ user, shared across every repo. The project-local one is a `.okf-registry.json`
221
+ that `okf registry init` drops in a directory; okf finds it by walking up from the
222
+ working directory, and while you are inside its tree it **replaces** the global one
223
+ — every registry op, and every [`@slug`](cli.md), resolves through it. So a bare
224
+ `okf server` inside a repo serves that repo's bundles with no `$OKF_HOME` setup,
225
+ and a project carries its own named set without touching the user's global list.
226
+
227
+ **The file's presence is the whole state.** There is no stored "local mode", the
228
+ same way the [default is a position, not a stored name](#the-default-is-a-position-not-a-stored-name):
229
+ a mode flag would be one more thing to set, dangle, and reconcile, where the file
230
+ being *there* is self-evident and self-cleaning. The nearest one on the path up
231
+ wins, so nested registries resolve nearest-first, and `okf registry list` names the
232
+ file it found so which one is answering is never a guess.
233
+
234
+ `$OKF_HOME` still names *where the global registry lives*; it does **not** veto a
235
+ nearer local one. That direction is deliberate: `$OKF_HOME` is commonly exported
236
+ once and left, so letting it win would silently defeat the feature for exactly the
237
+ users who set up a project registry. The escape hatch is therefore a per-invocation
238
+ signal, not a second sticky variable — `OKF_NO_DISCOVERY=1`, set inline, forces the
239
+ global registry for a fixed-cwd caller (CI, a tool) that cannot just `cd` out.
240
+
241
+ # A project-local registry stores portable paths
242
+
243
+ The global registry stores absolute paths — correct for `~/.okf`, whose bundles
244
+ are scattered across the disk with no shared anchor. A committed project registry
245
+ needs the opposite: a bundle **inside** the registry's own tree is stored *relative*
246
+ to the `.okf-registry.json`, so the file travels with the repo — a checkout on
247
+ another machine, or a container mounting it, resolves the same bundles unchanged. A
248
+ bundle **outside** the tree keeps an absolute path, because a relative path that
249
+ climbs out cannot be re-anchored anywhere useful, and being honest that it will not
250
+ travel beats a `../../..` that breaks on the first move.
251
+
252
+ The relative form lives **only on disk**. A path resolves to absolute the moment it
253
+ is read, so `entry.path`, [`registry list`](cli.md), and the server mount all go on
254
+ seeing the absolute paths they always did — the portability is a property of the
255
+ file, invisible to every consumer. And because only the write side relativizes, an
256
+ existing absolute local entry migrates to relative on its next write: a registry
257
+ written before this existed heals itself the first time it changes.
258
+ <!-- rule:okf-registry-local-discovery -->
259
+
260
+ # It costs an embedding app nothing
261
+
262
+ `require "okf"` does not load it. The registry is reached only from the
263
+ [CLI](cli.md), which requires it at the moment a registry verb or a bare `server`
264
+ runs — the same on-demand rule the [library API](capabilities/library-api.md)
265
+ keeps for the command-line machinery.