okf 1.7.0 → 1.8.0

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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack/utils"
4
+
3
5
  module OKF
4
6
  module Server
5
7
  # Renders an OKF::Bundle::Graph as the interactive graph page served by
@@ -40,7 +42,12 @@ module OKF
40
42
  # fetches a concept's raw markdown and metadata fragment from — relative so
41
43
  # the page works whether served at "/" or mounted under a Rails prefix.
42
44
  # +embed+ is the render-mode payload (nil = server mode); see the class doc.
43
- def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil)
45
+ # +siblings+/+self_slug+/+hub_path+ carry the hub's bundle switcher into the
46
+ # page (server mode only). nil — the standalone-server and `okf render`
47
+ # default — injects an empty SIBLINGS, so the switcher never appears in a
48
+ # single bundle or a static file.
49
+ def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil,
50
+ siblings: nil, self_slug: nil, hub_path: nil)
44
51
  @graph = graph
45
52
  @title = title
46
53
  @link = link
@@ -48,6 +55,9 @@ module OKF
48
55
  @node_endpoint = node_endpoint
49
56
  @meta_endpoint = meta_endpoint
50
57
  @embed = embed
58
+ @siblings = siblings
59
+ @self_slug = self_slug
60
+ @hub_path = hub_path
51
61
  end
52
62
 
53
63
  def render
@@ -102,6 +112,20 @@ module OKF
102
112
  json_for_script(@embed)
103
113
  end
104
114
 
115
+ # The hub switcher's data: the other bundles (empty when standalone/static),
116
+ # this bundle's slug, and the hub root — all </script>-escaped like the rest.
117
+ def siblings_json
118
+ json_for_script(@siblings || [])
119
+ end
120
+
121
+ def self_slug_json
122
+ json_for_script(@self_slug)
123
+ end
124
+
125
+ def hub_path_json
126
+ json_for_script(@hub_path)
127
+ end
128
+
105
129
  # JSON-encode for safe embedding in an inline <script>: escaping every `<` to
106
130
  # its JSON unicode escape neutralizes </script>, <!-- and <script in one
107
131
  # stroke, and the result stays valid JSON *and* JavaScript.
@@ -120,8 +144,10 @@ module OKF
120
144
  end
121
145
  end
122
146
 
147
+ # Rack's, not a hand-rolled one — this output goes into attributes
148
+ # (`href="…"`), so the escape set is load-bearing rather than cosmetic.
123
149
  def html_escape(str)
124
- str.to_s.gsub("&", "&amp;").gsub('"', "&quot;").gsub("<", "&lt;").gsub(">", "&gt;")
150
+ Rack::Utils.escape_html(str.to_s)
125
151
  end
126
152
  end
127
153
  end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ require "okf/server/app"
6
+
7
+ module OKF
8
+ module Server
9
+ # Multiplexes N bundles behind one server. Each bundle is mounted at
10
+ # /b/<slug>/ and served by its own OKF::Server::App; `/` redirects to the
11
+ # default bundle (explicitly chosen, or the first), or shows an empty-state
12
+ # page when none are registered. The graph page is already mount-relative (its fetch endpoints
13
+ # are relative), so hosting under a prefix needs only a clean PATH_INFO strip
14
+ # here plus a trailing-slash redirect. Part of the shell — it is a Rack app.
15
+ #
16
+ # GET / 302 -> /b/<default>/ (empty-state page when no bundles)
17
+ # GET /b/ the bundle index — every hosted bundle, default marked
18
+ # GET /b/<slug> 301 -> /b/<slug>/ (query string preserved)
19
+ # GET /b/<slug>/... delegated to that bundle's App (the prefix stripped)
20
+ # GET (unknown slug) 404 as a page listing the hosted bundles — a stale
21
+ # bookmark after a rename gets a way home, not bare text
22
+ #
23
+ # +bundles+ is an ordered array of Hub::Bundle (slug, folder, title). Apps are
24
+ # built up front, each carrying the *other* bundles as siblings so the in-page
25
+ # switcher can jump between them; static `okf render` files get no siblings and
26
+ # so cannot switch.
27
+ class Hub
28
+ MOUNT = "/b"
29
+
30
+ # One hosted bundle: its +slug+ (unique mount key), the on-disk +folder+, and
31
+ # its display +title+.
32
+ Bundle = Struct.new(:slug, :folder, :title)
33
+
34
+ # Shared style for the hub's own pages (empty landing, /b/ index, 404) —
35
+ # self-contained and theme-aware, no external requests, in keeping with the
36
+ # graph page's own no-CDN-at-rest rule.
37
+ STYLE = <<~CSS
38
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f7;color:#1f2328;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
39
+ main{max-width:34rem;width:calc(100% - 4rem);padding:2rem}h1{font-size:1.3rem;margin:0 0 .5rem}
40
+ code{background:#e6e8eb;padding:.15rem .4rem;border-radius:.35rem}
41
+ ul.bundles{list-style:none;margin:1rem 0 0;padding:0}
42
+ ul.bundles li{padding:.45rem 0;border-top:1px solid #e6e8eb;display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
43
+ ul.bundles a{color:inherit;font-weight:600;text-decoration:none}ul.bundles a:hover{text-decoration:underline}
44
+ .meta{color:#63697a;font-size:.85rem;white-space:nowrap}
45
+ .def{margin-left:.5rem;padding:.05rem .45rem;border-radius:99px;background:#e6e8eb;font-size:.75rem}
46
+ @media(prefers-color-scheme:dark){body{background:#111318;color:#eceef1}code,.def{background:#232833}
47
+ ul.bundles li{border-color:#2a2e36}.meta{color:#9aa0aa}}
48
+ CSS
49
+
50
+ # The hosted bundles in mount order, and the one `/` redirects to — so a
51
+ # caller printing the mount table asks the hub instead of re-deriving the
52
+ # rule and drifting from it.
53
+ attr_reader :bundles, :default
54
+
55
+ # The first bundle is the one `/` redirects to — the registry hands them over
56
+ # in its own order, where first *is* the default (`okf registry default`
57
+ # moves an entry to the front), and an ephemeral run takes the dirs as typed.
58
+ def initialize(bundles, layout: "cose")
59
+ @bundles = bundles
60
+ @default = bundles.first
61
+ @apps = build_apps(layout)
62
+ end
63
+
64
+ def call(env)
65
+ request = Rack::Request.new(env)
66
+ return not_found unless request.get?
67
+
68
+ path = request.path_info
69
+ query = request.query_string.to_s
70
+ # Everything this class *emits* must carry the prefix a host mounted it
71
+ # under; PATH_INFO is already relative to it.
72
+ base = env["SCRIPT_NAME"].to_s
73
+ return landing(base, query) if [ "", "/" ].include?(path)
74
+ return html(200, index_page(base)) if [ MOUNT, "#{MOUNT}/" ].include?(path)
75
+
76
+ slug, rest = split(path)
77
+ app = slug && @apps[slug]
78
+ return html(404, missing_page(base, path)) unless app
79
+ return redirect("#{base}#{MOUNT}/#{slug}/", 301, query) if rest.empty?
80
+
81
+ app.call(mounted(env, slug, rest))
82
+ end
83
+
84
+ private
85
+
86
+ # Split "/b/<slug>/rest" into [ "<slug>", "/rest" ] (rest "" for just
87
+ # "/b/<slug>"). A path outside the mount prefix, or an empty slug, is [ nil, nil ].
88
+ def split(path)
89
+ prefix = "#{MOUNT}/"
90
+ return [ nil, nil ] unless path.start_with?(prefix)
91
+
92
+ slug, slash, rest = path[prefix.length..-1].partition("/")
93
+ return [ nil, nil ] if slug.empty?
94
+
95
+ [ slug, slash + rest ]
96
+ end
97
+
98
+ # Concept counts for the listing pages, computed once. Bundle#graph is not
99
+ # memoized (App memoizes its own), and /b/ and every stray 404 render this
100
+ # list — without the memo a 404 flood reparses every hosted bundle.
101
+ def counts
102
+ @counts ||= @bundles.each_with_object({}) do |bundle, memo|
103
+ memo[bundle.slug] = bundle.folder.graph(minimal: true).nodes.size
104
+ end
105
+ end
106
+
107
+ # A copy of env aimed at the bundle's App: the /b/<slug> prefix moves from
108
+ # PATH_INFO to SCRIPT_NAME. The App ignores SCRIPT_NAME (its endpoints are
109
+ # relative), but keeping the split correct leaves the env well-formed.
110
+ def mounted(env, slug, rest)
111
+ env.merge(
112
+ "SCRIPT_NAME" => "#{env["SCRIPT_NAME"]}#{MOUNT}/#{slug}",
113
+ "PATH_INFO" => rest
114
+ )
115
+ end
116
+
117
+ def build_apps(layout)
118
+ @bundles.each_with_object({}) do |bundle, apps|
119
+ apps[bundle.slug] = App.new(
120
+ bundle.folder,
121
+ title: bundle.title,
122
+ layout: layout,
123
+ siblings: siblings_of(bundle),
124
+ self_slug: bundle.slug,
125
+ hub_path: "/"
126
+ )
127
+ end
128
+ end
129
+
130
+ # Every other bundle, as { slug:, title:, path:, default: } — what the
131
+ # switcher lists; default marks the bundle `/` opens. The path is
132
+ # *relative* because these are baked into each App at boot, before any
133
+ # request names a SCRIPT_NAME: every page lives at <prefix>/b/<slug>/, so
134
+ # "../<other>/" reaches its sibling under any mount and needs no prefix.
135
+ def siblings_of(bundle)
136
+ @bundles.reject { |other| other.slug == bundle.slug }
137
+ .map { |other| { slug: other.slug, title: other.title, path: "../#{other.slug}/", default: other.equal?(@default) } }
138
+ end
139
+
140
+ def landing(base, query = "")
141
+ return redirect("#{base}#{MOUNT}/#{@default.slug}/", 302, query) if @default
142
+
143
+ html(200, page("OKF · no bundles", <<~BODY))
144
+ <h1>No bundles registered</h1>
145
+ <p>Register one with <code>okf registry set &lt;dir&gt;</code>, then restart <code>okf server</code>.</p>
146
+ BODY
147
+ end
148
+
149
+ # The /b/ index — every hosted bundle with its mount link, concept count,
150
+ # and the default marked. The browser counterpart of `okf registry`.
151
+ def index_page(base)
152
+ page("OKF · bundles", "<h1>Bundles</h1>#{bundle_list(base)}")
153
+ end
154
+
155
+ # The 404 for a slug the hub does not host: name what was asked for, then
156
+ # list what exists — a stale bookmark after a rename gets a way home.
157
+ def missing_page(base, path)
158
+ body = "<h1>No bundle here</h1><p><code>#{escape(path)}</code> does not match a hosted bundle.</p>"
159
+ body += bundle_list(base) unless @bundles.empty?
160
+ page("OKF · not found", body)
161
+ end
162
+
163
+ def bundle_list(base)
164
+ rows = @bundles.map do |bundle|
165
+ count = counts[bundle.slug]
166
+ badge = bundle.equal?(@default) ? %(<span class="def">default</span>) : ""
167
+ %(<li><a href="#{escape(base)}#{MOUNT}/#{escape(bundle.slug)}/">#{escape(bundle.title)}</a>) +
168
+ %(<span class="meta">#{escape(bundle.slug)} · #{count} concepts#{badge}</span></li>)
169
+ end
170
+ %(<ul class="bundles">#{rows.join}</ul>)
171
+ end
172
+
173
+ def page(title, body)
174
+ <<~HTML
175
+ <!doctype html><html lang="en"><head><meta charset="utf-8">
176
+ <meta name="viewport" content="width=device-width,initial-scale=1">
177
+ <title>#{escape(title)}</title>
178
+ <style>#{STYLE}</style>
179
+ </head><body><main>#{body}</main></body></html>
180
+ HTML
181
+ end
182
+
183
+ def html(status, body)
184
+ [ status, { "content-type" => "text/html; charset=utf-8" }, [ body ] ]
185
+ end
186
+
187
+ # Keep the query string across redirects — `/b/notes?view=files` must land
188
+ # on the Files view, not reset to the default graph.
189
+ def redirect(location, status, query = "")
190
+ location += "?#{query}" unless query.empty?
191
+ [ status, { "location" => location, "content-type" => "text/plain; charset=utf-8" }, [ "" ] ]
192
+ end
193
+
194
+ def not_found
195
+ OKF::Server::App.not_found
196
+ end
197
+
198
+ # Rack's, not a fourth hand-rolled one: the server layer had three, each
199
+ # escaping a different set — App's left `"` alone, which is safe only
200
+ # while nothing interpolates it into an attribute. Rack::Utils covers
201
+ # & " ' < > and ships with the dependency we already have.
202
+ def escape(str)
203
+ Rack::Utils.escape_html(str.to_s)
204
+ end
205
+ end
206
+ end
207
+ end
@@ -150,7 +150,7 @@ Read the referenced playbook before executing — it *is* the procedure.
150
150
  | `consume` | Use | use the bundle as context for a task | [playbooks/consume.md](playbooks/consume.md) |
151
151
  | `curate` | Curate | structural upkeep as it stands: validate + lint + loose | [playbooks/curate.md](playbooks/curate.md) |
152
152
  | `doctor` | Setup | install and verify the CLI, then doctor the bundle | [playbooks/doctor.md](playbooks/doctor.md) |
153
- | `<okf-cli-verb>` | Read | validate, lint, loose, index, catalog, files, tags, types, stats, graph, server, render, skill | `okf <verb> --help` + [reference/cli.md](reference/cli.md) |
153
+ | `<okf-cli-verb>` | Read | validate, lint, loose, index, catalog, files, tags, types, stats, graph, server, render, registry, skill | `okf <verb> --help` + [reference/cli.md](reference/cli.md) |
154
154
 
155
155
  Two boundaries worth keeping sharp: `curate` is structural upkeep only — when
156
156
  the *content* no longer matches reality, that is `maintain` — and `doctor` is
@@ -21,6 +21,9 @@ reads, and full bodies are read last, and only the winners.
21
21
  for patterns like `err_[a-z]+_409`). Scope it with what the map taught you:
22
22
  `--area billing`, `--type Decision`, `--tag idempotency`, `--in body`.
23
23
  Matches rank by where they hit, and the snippet often *is* the answer.
24
+ When the answer may live in another registered bundle, span them — leading
25
+ @slugs (`okf search @handbook @notes <terms>`) or `@all` for every registered
26
+ one — and read the per-row bundle slug before following an id home.
24
27
  4. **Read only the winners.** A match row's `id` is its file: `<dir>/<id>.md`.
25
28
  Read that file — not its folder, never the whole tree. Follow its links (§5)
26
29
  one hop at a time; check `log.md` when freshness matters.
@@ -38,9 +38,36 @@ difference between a few hundred bytes and hundreds of KB, since the per-item ro
38
38
  (`listing`) dominate at scale. `okf index --no-body` is shorthand for dropping just
39
39
  `body`.
40
40
 
41
+ **Every output names its bundle.** Two keys, one meaning each: `bundle` is
42
+ always a directory, `slug` always a registry slug. Name a bundle by `@slug` and
43
+ the answer comes back in that identity — `OKF lint — @handbook (/path/to/one)`,
44
+ and `{ "bundle": "/path/to/one", "slug": "handbook", … }` — so an agent holding
45
+ several bundles never has to remember which invocation produced which output.
46
+ A bundle named by path carries no `slug`: it may not have one, and inventing a
47
+ name it was never given would imply a registration that does not exist.
48
+
49
+ **@slug — point any verb at a registered bundle.** Wherever a `<dir>` goes,
50
+ `@slug` names a bundle registered via `okf registry set`, and bare `@` the
51
+ registry's default. They resolve through `$OKF_HOME` (default `~/.okf`) — the
52
+ single lever on which registry *any* verb reads, and it names exactly one, with
53
+ no fallback behind it. The slug is normalized as registration
54
+ normalized it — `@One` finds the bundle from dir `One` — but never to a
55
+ placeholder: `@***` names nothing, not a bundle. An unknown slug, a
56
+ registered-but-gone directory, or a malformed registry file is a usage error
57
+ (exit 2) whose message names the registry file consulted and the next move —
58
+ an explicit ask fails hard, never silently skipped. So `okf lint @handbook`
59
+ or `okf index @` work from any directory, no path recall needed.
60
+
41
61
  **Exit codes:** `0` success · `1` non-conformant bundle (or a `lint --fail-on`
42
62
  threshold crossed) · `2` usage error. `graph`, `server`, and `render` are best-effort
43
- (§9): a file with invalid frontmatter is skipped and noted on stderr, never fatal.
63
+ (§9): a file the reader cannot use frontmatter that will not parse, or a file it
64
+ cannot open at all — is skipped and noted on stderr, never fatal. The note counts;
65
+ `validate` names each file and why.
66
+
67
+ **One bundle per verb, except two.** Only `search` merges several bundles and only
68
+ `server` mounts them; hand a second bundle to any other verb — two dirs, two refs,
69
+ or a mix — and it is a usage error (exit 2), never a silent answer about the first.
70
+ To ask the same question of several bundles, ask `search`, or ask each in turn.
44
71
 
45
72
  ## validate — the hard gate (§9)
46
73
 
@@ -119,16 +146,55 @@ expressions with `--regexp`/`-e` (an invalid pattern is a usage error, exit 2).
119
146
  body); the shared `--type/--area/--tag` filters narrow the candidates *first*,
120
147
  so a search scoped by what `index` taught you stays surgical.
121
148
 
149
+ **Search spans bundles.** Leading @refs pick several registered bundles
150
+ (`okf search @handbook @notes auth`); **`@all`** is the ref that means every one.
151
+ The per-bundle rankings merge — scores are absolute term weights, so they
152
+ compare across bundles — and each row carries its bundle's slug. This is the
153
+ cross-bundle retrieval the in-page search does not have: one question, every
154
+ bundle you keep. <!-- rule:okf-search-all -->
155
+
156
+ `@all` is a ref, not a flag, which is what keeps the grammar single: slot 1 is
157
+ always a bundle identity, so a directory there is a directory and nothing can
158
+ flip it into a term. Being a ref, it is normalized like one — `@ALL` and `@All`
159
+ name every bundle just as `@One` names the bundle registered from dir `One`. It composes accordingly — `@all @docs` expands and dedupes
160
+ (all ⊇ docs), needing no diagnostic. **Asking for everything tolerates gaps;
161
+ naming one bundle demands it**: `@all` skips a bundle whose directory has
162
+ vanished with a note on stderr, while `@docs` fails hard. `@all` is only
163
+ `search`'s: every other verb answers about one bundle, so it refuses `@all` by
164
+ name rather than letting the answer depend on how many bundles you happen to
165
+ have registered. `all` is reserved as a slug — a directory named `all/` registers
166
+ as `all-2`, `--as all` is refused, and an `all` row already in the registry file
167
+ (hand-typed, or written before the name was reserved) is read as `all-2` rather
168
+ than taken as grounds to reject the file — so `@all` is never ambiguous, and the
169
+ reservation never strands a registry it inherited. **The read normalizes every
170
+ slug** the same way registration would, so a hand-typed `"slug": "My Docs"` lists
171
+ and resolves as `my-docs`; an entry the listing shows is always an entry `@slug`,
172
+ `rename`, and `default` can name.
173
+
174
+ `--fields` projects the shape the mode actually emits: `slug` is available in
175
+ registry mode, and a usage error naming the real fields on a path-named search,
176
+ which has no slug to give. Two sharp edges: every *leading* @-arg is taken as a ref, so a literal @-term
177
+ (`@babel/core`, a Ruby `@ivar`) needs a non-@ term before it or `-e '\@term'` —
178
+ the CLI notes both traps on stderr — and any ref, even one, switches the JSON
179
+ envelope (next paragraph).
180
+
122
181
  Rows rank by **where** they hit — title 5, id 4, tags 3, type/description 2,
123
182
  body 1, summed over matched fields — and carry one bounded context snippet from
124
183
  the strongest match that needs context (description or body). Deliberately not
125
184
  fuzzy: the consuming agent is the fuzzy layer — when terms miss, learn the
126
185
  bundle's vocabulary from `tags`/`types` and re-ask in its own words, rather
127
186
  than hammering synonyms. Advisory read: **exit 0 even with zero matches**.
128
- JSON: `{ bundle, query, count, matches: [{ id, title, type, area, tags,
129
- matched, score, snippet }] }`, projectable with `--fields/--except`. The
130
- retrieval procedure that puts this verb in sequence map first, finder second,
131
- bodies last is the [search playbook](../playbooks/search.md).
187
+ JSON, plain-dir mode: `{ bundle, query, count, matches: [{ id, title, type,
188
+ area, tags, matched, score, snippet }] }`. Registry mode any leading @ref,
189
+ `@all` among them swaps the envelope: `{ bundles: [{ slug, dir }, …],
190
+ query, count, matches: [{ slug, id, … }] }`; a parser must branch on which form
191
+ it called. The head maps each slug to its dir once, so a row resolves to
192
+ `<dir>/<id>.md` without a second lookup and without repeating a path per row.
193
+ Both are projectable with `--fields/--except`, and projection is literal — when
194
+ merging bundles, put `slug` in your `--fields` list or the row label drops and
195
+ same-id concepts from different bundles become indistinguishable. The retrieval procedure that puts this verb in sequence —
196
+ map first, finder second, bodies last — is the
197
+ [search playbook](../playbooks/search.md).
132
198
 
133
199
  ## index — the progressive-disclosure map (§6)
134
200
 
@@ -216,6 +282,56 @@ just-appended entry shows without a restart. `?view=index` jumps straight to
216
282
  the Indexes tab. It is a Rack app, so the same server can be mounted in a
217
283
  host app (e.g. Rails).
218
284
 
285
+ **Hosting many bundles (the hub).** `okf server` takes zero or more dirs.
286
+ One dir is the classic single bundle at `/`. Two or more mounts each under
287
+ `/b/<slug>/` behind a hub, `/` redirects to the default, and `/b/` is a
288
+ self-contained **bundle index** (every hosted bundle, concept counts, default
289
+ marked — the browser counterpart of `okf registry`). An unknown slug 404s as a
290
+ page listing the hosted bundles, so a stale bookmark after a rename gets a way
291
+ home. With **no** dir it serves the *persistent registry*, a plain JSON file
292
+ under `$OKF_HOME` (default `~/.okf`), managed by the
293
+ `okf registry` umbrella — like git's `remote` family, and split by what each
294
+ verb keys on. **Entry verbs** take a path: `okf registry set <dir>` adds it
295
+ (slug from the basename, or `--as`, which errors on a collision; `--default`
296
+ puts it first), and because the entry is keyed by path, `set` on an
297
+ already-registered dir updates it in place — refreshing its title, and renaming
298
+ it when `--as` is given. `okf registry del <dir|@slug>` removes one — by name, so an entry whose
299
+ directory is already gone still deletes. Slug *or* dir, never both readings at
300
+ once: an argument with a `/` in it names a location and only a location, so
301
+ `del ./notes` refuses when no entry points there rather than stripping to the
302
+ slug `notes` and deleting a bundle somewhere else entirely.
303
+ <!-- rule:okf-registry-del-path-or-slug -->
304
+ **Slug verbs** take the name — bare, or as an `@slug`: `okf registry default <@slug>`
305
+ chooses which bundle `/` opens **by moving that entry to the front**, and
306
+ `okf registry rename <@slug> <new>` renames a slug (mount path and switcher
307
+ name) — `<new>` is a name being minted, so it is never a ref. The registry is ordered and **the first entry still on disk is the
308
+ default** — that is the whole rule, so the first bundle you register is the
309
+ default until you move another one, a rename keeps its position, and a `del`
310
+ promotes whatever is next. A vanished directory is stepped over (the server
311
+ cannot open one, so starring it would name a bundle `/` never serves), and
312
+ `registry default @slug` refuses one outright — the same refusal `registry set`
313
+ gives a directory that is not there. The file is hand-editable and reorders
314
+ visibly, which is the point: there is no stored slug that can dangle.
315
+ <!-- rule:okf-registry-default-position -->
316
+ `okf registry list` (or a bare
317
+ `okf registry`) stars the default and flags vanished dirs `(missing)` — the
318
+ server skips those with a note; `--json` answers
319
+ `{ registry: <file>, count, bundles: [{ slug, title, dir, mount, default,
320
+ missing }] }`, naming the file it read so a `$OKF_HOME` mismatch is visible. The hub roster is a
321
+ **boot-time snapshot**: restart `okf server` after registry changes. Behind a
322
+ hub the page gains a **bundle switcher** (⌘/Ctrl-K, or the rail button with its
323
+ bundle-count badge): the current bundle is pinned, the default chipped; ⏎
324
+ opens, ⌘/Ctrl-⏎ opens a new tab, and the current view carries over. Switching
325
+ is a server-only affordance — a static `render` file has no siblings and shows
326
+ none.
327
+
328
+ **Bundle-less run.** Register bundles once, then `okf server` (no dir) hosts
329
+ them all with the registry's first entry still on disk at `/` — the way to keep
330
+ several bundles a keystroke apart without re-passing paths.
331
+ `okf server @a @b` serves a registry subset, each mounted under its registered
332
+ slug — but as with any dirs-given run, the *first argument* lands at `/`; the
333
+ registry's own order applies only to the bundle-less run.
334
+
219
335
  **Trust boundary:** the page renders each fetched markdown body through
220
336
  DOMPurify and escapes everything it inlines (every `<` in the graph data is
221
337
  escaped, so it cannot break out of its `<script>`), but it still loads its
@@ -242,10 +358,12 @@ you trust.
242
358
 
243
359
  ## graph — the raw structure
244
360
 
245
- Prints the node/edge graph. `--json` emits a machine-readable dump (`nodes` with
361
+ Prints the node/edge graph. `--json` emits a machine-readable dump the
362
+ `bundle`/`slug` head every view carries, then `nodes` (with
246
363
  `id`/`type`/`title`/`description`/`tags` **and, by default, every `body`** — the
247
- part that dominates the bytes on a real bundle plus `edges`) you can pipe into
248
- other analysis. To *plan* a traversal, structure is all you need: `--no-body`
364
+ part that dominates the bytes on a real bundle) plus `edges` you can pipe into
365
+ other analysis. A concept with a missing *or blank* `type` indexes under
366
+ `Untyped`: §9.2 rejects both identically, so both land in one bucket. To *plan* a traversal, structure is all you need: `--no-body`
249
367
  drops each node's body, and `--minimal` ships only `id`/`title` plus the type/tag
250
368
  indexes — the lean shape the `server` page boots from. Reach for the full dump
251
369
  only when the task truly consumes every body; for one question, the
data/lib/okf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OKF
4
- VERSION = "1.7.0"
4
+ VERSION = "1.8.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -76,9 +76,11 @@ files:
76
76
  - lib/okf/markdown/frontmatter.rb
77
77
  - lib/okf/markdown/links.rb
78
78
  - lib/okf/path.rb
79
+ - lib/okf/registry.rb
79
80
  - lib/okf/server/app.rb
80
81
  - lib/okf/server/graph.rb
81
82
  - lib/okf/server/graph/template.html.erb
83
+ - lib/okf/server/hub.rb
82
84
  - lib/okf/server/runner.rb
83
85
  - lib/okf/skill.rb
84
86
  - lib/okf/skill/SKILL.md