okf 1.7.0 → 1.9.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,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack/utils"
4
+
3
5
  module OKF
4
- module Server
5
- # Renders an OKF::Bundle::Graph as the interactive graph page served by
6
- # OKF::Server::App. The markup lives in graph/template.html.erb; #render
7
- # returns the HTML string.
6
+ # The view layer: turns a bundle into the interactive graph page. Pairs with the
7
+ # pure OKF::Bundle::Graph (the data model) Bundle builds the graph, Render
8
+ # draws it. A shell (reads the template, escapes with rack/utils), but knows
9
+ # nothing about HTTP: OKF::Server::App serves what this produces, and `okf
10
+ # render` writes it to a file, from the one class.
11
+ module Render
12
+ # Renders an OKF::Bundle::Graph as the interactive graph page. The markup lives
13
+ # in graph/template.html.erb; #render returns the HTML string.
8
14
  #
9
15
  # The page boots from a *minimal* payload — nodes carry only id + title, plus
10
16
  # compact TYPES/TAGS inverted indexes for colouring and filtering. It has two
@@ -14,9 +20,9 @@ module OKF
14
20
  # metadata, catalog, index and log are pulled from OKF::Server::App on
15
21
  # demand via fetch, so the initial payload stays small and bodies read
16
22
  # live from disk (edits show without a restart). Nothing extra embedded.
17
- # render mode (embed: payload) — `okf render` bakes the whole bundle in:
18
- # the same fetch getters resolve from the injected payload instead, so
19
- # the single file needs no server (e.g. hosting on GitHub Pages).
23
+ # render mode (embed: payload) — `okf render` bakes the whole bundle in via
24
+ # .static below: the same fetch getters resolve from the injected payload
25
+ # instead, so the single file needs no server (e.g. GitHub Pages).
20
26
  #
21
27
  # NOTE (trust boundary): the page loads Cytoscape + marked from a CDN, so it
22
28
  # needs network for those libraries even in render mode. Fetched/embedded
@@ -36,11 +42,38 @@ module OKF
36
42
  # built from the backslash code point so no literal escape appears here.
37
43
  LT_ESCAPE = (92.chr(Encoding::UTF_8) + "u003c").freeze
38
44
 
45
+ # `okf render`: the whole page as one self-contained file, the bundle baked
46
+ # in, so it hosts where no server answers a fetch. Takes any bundle handle
47
+ # (an OKF::Bundle::Folder) and returns the HTML string.
48
+ def self.static(folder, title: nil, link: nil, layout: "cose")
49
+ new(folder.graph(minimal: true), title: title || folder.name, link: link, layout: layout, embed: payload(folder)).render
50
+ end
51
+
52
+ # What the baked page carries in place of the endpoints a live server would
53
+ # answer. Every key here is data a client getter reads from EMBED instead of
54
+ # fetching — and each derives from the *same* folder method the matching
55
+ # OKF::Server::App endpoint uses, so the bake and the live server cannot
56
+ # drift (/node/meta is the exception: the fragment is derived on the client
57
+ # from the catalog's raw description, so no map is baked for it).
58
+ def self.payload(folder)
59
+ {
60
+ catalog: folder.catalog,
61
+ index: folder.directory_index,
62
+ logs: folder.log_entries,
63
+ bodies: folder.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
64
+ }
65
+ end
66
+
39
67
  # +node_endpoint+/+meta_endpoint+ are the (mount-relative) URLs the page
40
68
  # fetches a concept's raw markdown and metadata fragment from — relative so
41
69
  # the page works whether served at "/" or mounted under a Rails prefix.
42
70
  # +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)
71
+ # +siblings+/+self_slug+/+hub_path+ carry the hub's bundle switcher into the
72
+ # page (server mode only). nil — the standalone-server and `okf render`
73
+ # default — injects an empty SIBLINGS, so the switcher never appears in a
74
+ # single bundle or a static file.
75
+ def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil,
76
+ siblings: nil, self_slug: nil, hub_path: nil)
44
77
  @graph = graph
45
78
  @title = title
46
79
  @link = link
@@ -48,6 +81,9 @@ module OKF
48
81
  @node_endpoint = node_endpoint
49
82
  @meta_endpoint = meta_endpoint
50
83
  @embed = embed
84
+ @siblings = siblings
85
+ @self_slug = self_slug
86
+ @hub_path = hub_path
51
87
  end
52
88
 
53
89
  def render
@@ -64,6 +100,12 @@ module OKF
64
100
  html_escape(graph_name)
65
101
  end
66
102
 
103
+ # The bundle's own name, for the client: what the header already shows, so
104
+ # the page can label the root with it instead of `(root)` or `/`.
105
+ def name_json
106
+ json_for_script(graph_name)
107
+ end
108
+
67
109
  def og_title
68
110
  html_escape("OKF · #{graph_name}")
69
111
  end
@@ -102,6 +144,20 @@ module OKF
102
144
  json_for_script(@embed)
103
145
  end
104
146
 
147
+ # The hub switcher's data: the other bundles (empty when standalone/static),
148
+ # this bundle's slug, and the hub root — all </script>-escaped like the rest.
149
+ def siblings_json
150
+ json_for_script(@siblings || [])
151
+ end
152
+
153
+ def self_slug_json
154
+ json_for_script(@self_slug)
155
+ end
156
+
157
+ def hub_path_json
158
+ json_for_script(@hub_path)
159
+ end
160
+
105
161
  # JSON-encode for safe embedding in an inline <script>: escaping every `<` to
106
162
  # its JSON unicode escape neutralizes </script>, <!-- and <script in one
107
163
  # stroke, and the result stays valid JSON *and* JavaScript.
@@ -120,8 +176,10 @@ module OKF
120
176
  end
121
177
  end
122
178
 
179
+ # Rack's, not a hand-rolled one — this output goes into attributes
180
+ # (`href="…"`), so the escape set is load-bearing rather than cosmetic.
123
181
  def html_escape(str)
124
- str.to_s.gsub("&", "&amp;").gsub('"', "&quot;").gsub("<", "&lt;").gsub(">", "&gt;")
182
+ Rack::Utils.escape_html(str.to_s)
125
183
  end
126
184
  end
127
185
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "rack"
4
4
 
5
- require "okf/server/graph"
5
+ require "okf/render/graph"
6
6
 
7
7
  module OKF
8
8
  module Server
@@ -11,7 +11,7 @@ module OKF
11
11
  #
12
12
  # mount OKF::Server::App.new(folder) => "/knowledge"
13
13
  #
14
- # The page (OKF::Server::Graph) boots from a *minimal* graph (id + title + edges
14
+ # The page (OKF::Render::Graph) boots from a *minimal* graph (id + title + edges
15
15
  # + type/tag indexes) and pulls each concept's markdown body and description from
16
16
  # here on demand, so the initial payload stays small and bodies are read live
17
17
  # from disk (edits show without a restart). Part of the shell — it does I/O.
@@ -31,11 +31,18 @@ module OKF
31
31
  # dir, content} ] } (JSON; content read live from disk,
32
32
  # like a body — the log is the file that changes most)
33
33
  class App
34
- def initialize(folder, title: nil, link: nil, layout: "cose")
34
+ # +siblings+/+self_slug+/+hub_path+ are set only when this app is hosted under
35
+ # a hub (OKF::Server::Hub): the other bundles the in-page switcher offers, this
36
+ # bundle's own mount slug, and the hub root. They stay nil for a standalone
37
+ # server and for `okf render`, so a static file never advertises a switcher.
38
+ def initialize(folder, title: nil, link: nil, layout: "cose", siblings: nil, self_slug: nil, hub_path: nil)
35
39
  @folder = folder
36
40
  @title = title
37
41
  @link = link
38
42
  @layout = layout
43
+ @siblings = siblings
44
+ @self_slug = self_slug
45
+ @hub_path = hub_path
39
46
  end
40
47
 
41
48
  def call(env)
@@ -55,11 +62,9 @@ module OKF
55
62
  end
56
63
  end
57
64
 
58
- # The same interactive page, but with the whole bundle baked in bodies,
59
- # catalog, index and logs — so it needs no server. This is what `okf render`
60
- # writes: the fetch getters resolve from the embedded payload, not from here.
61
- def render_static
62
- Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout, embed: embed_payload).render
65
+ # The 404 both this app and the Hub answer with, so the two cannot drift.
66
+ def self.not_found
67
+ [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "not found\n" ] ]
63
68
  end
64
69
 
65
70
  private
@@ -85,45 +90,18 @@ module OKF
85
90
  { directories: @folder.directory_index }
86
91
  end
87
92
 
88
- # Every log.md with its content, root scope first. Content is read live
89
- # from disk so a just-appended entry shows without a restart; paths come
90
- # from the loaded bundle, never from the request.
93
+ # The §7 history the Log panel renders: every log.md with its content, root
94
+ # scope first, read live from disk. Built by OKF::Bundle::Folder#log_entries,
95
+ # shared with `okf render`'s bake so the served and baked logs cannot drift.
91
96
  def logs
92
- entries = @folder.bundle.log_files.sort_by { |path| [ path == "log.md" ? 0 : 1, path ] }
93
- { logs: entries.map { |path| { path: path, dir: File.dirname(path), content: log_content(path) } } }
94
- end
95
-
96
- def log_content(path)
97
- File.read(File.join(@folder.root, path), encoding: "UTF-8")
98
- rescue SystemCallError
99
- @folder.bundle.reserved_content(path)
97
+ { logs: @folder.log_entries }
100
98
  end
101
99
 
102
100
  def page
103
- @page ||= Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout).render
104
- end
105
-
106
- # Everything the on-demand endpoints would serve, baked for render mode. The
107
- # arrays match what each client getter extracts from the JSON envelope; the
108
- # per-concept maps mirror /node (raw, unstripped body) and /node/meta (the
109
- # same escaped fragment). Read from the in-memory bundle — no live disk read,
110
- # since a static file is a snapshot, not a window on edits.
111
- def embed_payload
112
- {
113
- catalog: @folder.catalog,
114
- index: @folder.directory_index,
115
- logs: logs[:logs],
116
- bodies: bodies,
117
- meta: meta
118
- }
119
- end
120
-
121
- def bodies
122
- @folder.bundle.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
123
- end
124
-
125
- def meta
126
- @folder.bundle.concepts.each_with_object({}) { |concept, map| map[concept.id] = description_fragment(concept) }
101
+ @page ||= OKF::Render::Graph.new(
102
+ graph, title: @title || @folder.name, link: @link, layout: @layout,
103
+ siblings: @siblings, self_slug: @self_slug, hub_path: @hub_path
104
+ ).render
127
105
  end
128
106
 
129
107
  def node_body(id)
@@ -168,11 +146,11 @@ module OKF
168
146
  end
169
147
 
170
148
  def not_found
171
- [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "not found\n" ] ]
149
+ self.class.not_found
172
150
  end
173
151
 
174
152
  def html_escape(str)
175
- str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
153
+ Rack::Utils.escape_html(str.to_s)
176
154
  end
177
155
  end
178
156
  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
@@ -5,14 +5,15 @@ description: >-
5
5
  directory of markdown files with YAML frontmatter that humans and agents read
6
6
  from one source. Use when capturing knowledge into a bundle (a service, schema,
7
7
  metric, decision, runbook: "document this in OKF", "capture this as a concept"),
8
+ converting existing docs into one ("migrate/OKFy our docs into a bundle"),
8
9
  retrieving from one without reading it whole ("what do we know about X?", "where
9
- is X documented?", "search the bundle"), updating one after code or docs change
10
- ("update the knowledge bundle"), checking its conformance or curation quality
11
- ("validate/lint the bundle"), serving or rendering it as a graph, or working in a
12
- repo that already carries an OKF bundle — a `.okf/` directory or a root `index.md`
13
- carrying `okf_version`.
10
+ is X documented?", "search the bundle"), updating one after code or docs
11
+ change ("update the knowledge bundle"), checking its conformance or curation
12
+ quality ("validate/lint the bundle"), serving or rendering it as a graph, or
13
+ working in a repo that already carries an OKF bundle — a `.okf/` directory or a
14
+ root `index.md` carrying `okf_version`.
14
15
  user-invocable: true
15
- argument-hint: "[search|produce|maintain|consume|<okf-cli-verb>] [dir] [--flags]"
16
+ argument-hint: "[search|produce|migrate|maintain|consume|curate|doctor|<okf-cli-verb>] [dir|@slug] [--flags]"
16
17
  allowed-tools: Read Write Edit Grep Glob Bash
17
18
  ---
18
19
 
@@ -66,12 +67,16 @@ earn your keep as the expert, not the executable.
66
67
 
67
68
  ## The CLI is your eyes — you are the judgment
68
69
 
69
- Guard once, then trust it — the `okf` executable answers every mechanical question
70
- deterministically, and its read views show everything the browser UI does:
71
-
72
- ```bash
73
- command -v okf >/dev/null || echo "okf CLI missing — install: gem install okf (or from a checkout: cd gem && bundle exec rake install)"
74
- ```
70
+ The `okf` executable answers every mechanical question deterministically, and its
71
+ read views show everything the browser UI does. **Don't probe for it — just run
72
+ the verb.** A proactive `command -v okf` before every task spends a whole tool
73
+ round proving what the next command reveals for free; the CLI's own failure is a
74
+ cheaper, truer signal. (The two deliberate exceptions are [menu](playbooks/menu.md)
75
+ and [doctor](playbooks/doctor.md) — both decide *whether to install*, so they check
76
+ first.) The one distinction to hold: a shell `okf: command not
77
+ found` is the *only* thing that means "install it" (→ [doctor](playbooks/doctor.md));
78
+ every line that starts `error:` is okf *answering* — a bundle or usage result to
79
+ read and act on, never a missing toolchain to send to doctor.
75
80
 
76
81
  Don't memorize the surface — `okf --help` maps every verb, `okf <verb> --help` its
77
82
  flags. The division of labour is the whole game:
@@ -98,7 +103,7 @@ shapes, the tag-curation views, the server's trust boundary.
98
103
  ## Orient before you touch anything
99
104
 
100
105
  Picking up a bundle you don't already know — to consume or maintain — run `okf
101
- index <dir>` (the §6 map: every directory's index body, rollups, and listings) and
106
+ index <dir|@slug>` (the §6 map: every directory's index body, rollups, and listings) and
102
107
  read `log.md` (the §7 baseline of what changed last) **before** greping or opening
103
108
  leaves. It is the cheapest high-signal context, and the only reliable way to catch
104
109
  enumeration drift: **grep cannot find an index entry that is missing** — you can't
@@ -123,9 +128,15 @@ is X documented" → `search`; a repo already carrying a bundle plus a task
123
128
  needing its knowledge → `consume`; "check / graph / preview it" → run the
124
129
  matching CLI verb and interpret the result. When genuinely ambiguous, ask.
125
130
 
126
- **Which directory?** Use the path given. Otherwise default to `.okf/` at the repo
127
- root, but first detect whether the project already keeps its bundle elsewhere
128
- (e.g. `docs/`) and prefer that. Commit the bundle alongside the code it describes.
131
+ **Which target?** A leading `@` is a *registry ref*, not a path: `@slug` names a
132
+ bundle registered with `okf registry set`, bare `@` the default route it
133
+ straight to `okf <verb> @slug` and skip the directory hunt (`okf search` spans
134
+ several: `@a @b`, or `@all`). A plain path is used as given. Given no target and a
135
+ cwd that carries no bundle, `okf registry list` is the next move, not a hunt
136
+ across sibling directories. Producing a *new* bundle with no path? Default to
137
+ `.okf/` at the repo root, but first detect whether the project already keeps its
138
+ bundle elsewhere (e.g. `docs/`) and prefer that; commit it alongside the code it
139
+ describes.
129
140
 
130
141
  **Target isn't a bundle?** When a verb points at a directory that holds markdown
131
142
  but no root `index.md` carrying `okf_version` — `validate` failing wholesale on
@@ -150,7 +161,7 @@ Read the referenced playbook before executing — it *is* the procedure.
150
161
  | `consume` | Use | use the bundle as context for a task | [playbooks/consume.md](playbooks/consume.md) |
151
162
  | `curate` | Curate | structural upkeep as it stands: validate + lint + loose | [playbooks/curate.md](playbooks/curate.md) |
152
163
  | `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) |
164
+ | `<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
165
 
155
166
  Two boundaries worth keeping sharp: `curate` is structural upkeep only — when
156
167
  the *content* no longer matches reality, that is `maintain` — and `doctor` is
@@ -1,8 +1,10 @@
1
1
  # Playbook: consume — use a bundle as context
2
2
 
3
- 1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf index <dir>` maps the
4
- whole bundle in one pass — every directory's index body, rollups, and listings —
5
- and `log.md` gives recent history. Then follow links only into the concepts the
3
+ 1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf index <dir|@slug>` maps
4
+ the whole bundle in one pass — every directory's index body, rollups, and listings —
5
+ and `log.md` gives recent history. Address a registered bundle by `@slug` (bare
6
+ `@` = the default); if the cwd carries no bundle, `okf registry list` finds one
7
+ instead of a directory hunt. Then follow links only into the concepts the
6
8
  task needs. For a *pointed question* rather than broad context, switch to the
7
9
  [search playbook](search.md): map → finder (`okf search`) → only the winning
8
10
  bodies. For a large bundle, `okf graph --json --minimal` gives the whole link
@@ -43,7 +43,7 @@ up. The modelling craft behind steps 3 and 7 lives in
43
43
  by design only through its index — leave it. **Terminal-by-design is not a
44
44
  defect.** Loose ≠ orphan: an index listing makes a file *reachable* (not an
45
45
  orphan) but is not a graph edge, so an indexed file can still float here.
46
- 7. **Curate the tag vocabulary** <!-- rule:okf-tag-vocabulary --> when the pass
46
+ 7. **Curate the tag vocabulary** when the pass
47
47
  touched tags, or when `okf tags <dir>` shows a long tail of singletons. Run `okf tags <dir> --by area` and
48
48
  `--by type` — the grouped view is the analysis; read each group top-down:
49
49
  - **twins** — two tags riding the exact same concepts (equal counts sort them
@@ -6,9 +6,13 @@ can query cheaply is dead weight. The discipline is progressive disclosure
6
6
  (spec §6): every step pays a few hundred bytes to decide what the next step
7
7
  reads, and full bodies are read last, and only the winners.
8
8
 
9
- 1. **Guard once**: `command -v okf`. Missing [doctor](doctor.md). No CLI at
10
- all read the root `index.md`, then each relevant area's `index.md`, by hand.
11
- 2. **Ingest the map and decide where to look.** `okf index <dir> --no-body` is
9
+ 1. **Just run it no presence probe.** Point the finder at a path or an `@slug`
10
+ (a registered bundle; bare `@` = the default). Only a shell `okf: command not
11
+ found` means install (→ [doctor](doctor.md)); with no CLI possible at all, read
12
+ the root `index.md` then each relevant area's `index.md` by hand. No bundle in
13
+ the cwd? `okf registry list` names the registered ones — address them by
14
+ `@slug`, don't hunt sibling directories.
15
+ 2. **Ingest the map and decide where to look.** `okf index <dir|@slug> --no-body` is
12
16
  the skeleton: every directory with its concept count, types, tags, children.
13
17
  *You* do the semantic matching here — the question names a meaning, the map
14
18
  names areas; connect them by judgment, not string equality. When an area
@@ -17,10 +21,42 @@ reads, and full bodies are read last, and only the winners.
17
21
  <!-- rule:okf-search-map-first -->
18
22
  3. **Cut across with the finder when the question is lexical.** An exact
19
23
  symbol, an error code, a column name, a phrase — things structure won't
20
- surface — go to `okf search <dir> <terms>` (terms AND together; `--regexp`
21
- for patterns like `err_[a-z]+_409`). Scope it with what the map taught you:
24
+ surface — go to `okf search <dir> <terms>`. Terms AND together and are matched
25
+ **literally against raw text**, so an exact query means what it looks like: a
26
+ phrase, a dotted version (`7.2.0`), an underscored identifier (`customer_id`),
27
+ a mid-word fragment (`ustomer`) and a word written in `backticks` all match
28
+ the way you typed them. <!-- rule:okf-search-exact-identifiers -->
29
+
30
+ **Match the engine to the shape of the query, not to habit** — the default
31
+ answers most of them, and the two engines fail in opposite directions:
32
+ <!-- rule:okf-search-engine-choice -->
33
+
34
+ | Your query is | Reach for | Because |
35
+ |---|---|---|
36
+ | an identifier, version, path, phrase, or anything in `` `backticks` `` | *nothing — the default* | matched literally; the index shatters all of these |
37
+ | a mid-word fragment (`ustomer`) | *nothing — the default* | an infix is not a token, so the index cannot reach it |
38
+ | a pattern (`err_[a-z]+_409`) | `-e` | Ruby regexp over raw text; still the scan |
39
+ | a partial word (`dedup` → `deduplication`) | *nothing — the default* | substring covers prefixes, and suffixes and infixes too |
40
+ | a theme, where you want the best match to lead | `--engine index` | BM25+ ranks by relevance, not by summed field weight |
41
+ | possibly mistyped | `--fuzzy` | edit distance 0.2 × term length — the index's alone |
42
+ | being reconciled with the browser page | `--engine index` | same MiniSearch build, so the two rank alike |
43
+
44
+ The index has exactly **three** things the default lacks: relevance ranking,
45
+ typo tolerance, and page parity. Its `prefix` capability is not a fourth — a
46
+ substring match already reaches every prefix, so `prefix` is what the index
47
+ needs to *catch up*, not a reason to choose it.
48
+
49
+ **`--fuzzy` is an engine switch, not a mode.** It routes to the index, so a
50
+ run that only wanted a typo forgiven also gets token matching, shattered
51
+ identifiers and unfindable code spans. Fix the spelling and stay on the
52
+ default when you can. <!-- rule:okf-search-fuzzy-is-a-switch -->
53
+
54
+ Scope any of them with what the map taught you:
22
55
  `--area billing`, `--type Decision`, `--tag idempotency`, `--in body`.
23
56
  Matches rank by where they hit, and the snippet often *is* the answer.
57
+ When the answer may live in another registered bundle, span them — leading
58
+ @slugs (`okf search @handbook @notes <terms>`) or `@all` for every registered
59
+ one — and read the per-row bundle slug before following an id home.
24
60
  4. **Read only the winners.** A match row's `id` is its file: `<dir>/<id>.md`.
25
61
  Read that file — not its folder, never the whole tree. Follow its links (§5)
26
62
  one hop at a time; check `log.md` when freshness matters.
@@ -37,6 +73,13 @@ Anti-patterns, each a real token bill:
37
73
  - **Grep before map.** Grep cannot find the entry that is *missing*, and it
38
74
  returns line noise where `search` returns ranked concepts. Grep is the
39
75
  fallback when the CLI is absent, not the first move.
40
- - **Mechanical synonym retries.** The finder is exact by design; *you* are the
76
+ - **Mechanical synonym retries.** The finder is exact by default; *you* are the
41
77
  fuzzy layer. When terms miss, learn the bundle's vocabulary — `okf tags
42
- <dir>`, `okf types <dir>` — and re-ask in its own words.
78
+ <dir>`, `okf types <dir>` — and re-ask in its own words. `--fuzzy` forgives a
79
+ *typo*, not a wrong vocabulary, so it is the wrong reach for this.
80
+ - **Flag-shopping a query that found nothing.** Cycling `--fuzzy`, then
81
+ `--engine index`, then `-e` over the same terms is guessing, and each engine
82
+ fails differently enough that one of them eventually returns *something* —
83
+ which is how a wrong answer gets found. Zero matches is usually a vocabulary
84
+ result, not an engine result: go back to the map and the tag list. Reach for a
85
+ different engine when you can say which property of the query needs it.