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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +368 -0
- data/README.md +78 -24
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/graph.rb +5 -1
- data/lib/okf/bundle/linter.rb +6 -1
- data/lib/okf/bundle/reader.rb +21 -4
- data/lib/okf/bundle/search/index.rb +65 -0
- data/lib/okf/bundle/search/scan.rb +89 -0
- data/lib/okf/bundle/search.rb +262 -66
- data/lib/okf/bundle.rb +2 -2
- data/lib/okf/cli.rb +917 -131
- data/lib/okf/registry.rb +370 -0
- data/lib/okf/{server → render}/graph/template.html.erb +1081 -130
- data/lib/okf/{server → render}/graph.rb +67 -9
- data/lib/okf/server/app.rb +23 -45
- data/lib/okf/server/hub.rb +207 -0
- data/lib/okf/skill/SKILL.md +28 -17
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/maintain.md +1 -1
- data/lib/okf/skill/playbooks/search.md +50 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +200 -23
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +21 -3
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
3
5
|
module OKF
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
19
|
-
# the single file needs no server (e.g.
|
|
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
|
-
|
|
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
|
|
182
|
+
Rack::Utils.escape_html(str.to_s)
|
|
125
183
|
end
|
|
126
184
|
end
|
|
127
185
|
end
|
data/lib/okf/server/app.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rack"
|
|
4
4
|
|
|
5
|
-
require "okf/
|
|
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::
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
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
|
-
|
|
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(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
149
|
+
self.class.not_found
|
|
172
150
|
end
|
|
173
151
|
|
|
174
152
|
def html_escape(str)
|
|
175
|
-
str.to_s
|
|
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 <dir></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
|
data/lib/okf/skill/SKILL.md
CHANGED
|
@@ -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
|
|
10
|
-
("update the knowledge bundle"), checking its conformance or curation
|
|
11
|
-
("validate/lint the bundle"), serving or rendering it as a graph, or
|
|
12
|
-
repo that already carries an OKF bundle — a `.okf/` directory or a
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
4
|
-
whole bundle in one pass — every directory's index body, rollups, and listings —
|
|
5
|
-
and `log.md` gives recent history.
|
|
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**
|
|
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. **
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
21
|
-
|
|
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
|
|
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.
|