okf 1.4.0 → 1.6.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +88 -43
  4. data/lib/okf/bundle/search.rb +155 -0
  5. data/lib/okf/bundle.rb +2 -2
  6. data/lib/okf/cli.rb +98 -4
  7. data/lib/okf/server/app.rb +59 -0
  8. data/lib/okf/server/graph/template.html.erb +271 -45
  9. data/lib/okf/server/graph.rb +25 -9
  10. data/lib/okf/skill/SKILL.md +23 -21
  11. data/lib/okf/skill/playbooks/consume.md +4 -2
  12. data/lib/okf/skill/playbooks/maintain.md +6 -4
  13. data/lib/okf/skill/playbooks/menu.md +10 -6
  14. data/lib/okf/skill/playbooks/search.md +42 -0
  15. data/lib/okf/skill/reference/authoring.md +2 -2
  16. data/lib/okf/skill/reference/cli.md +73 -18
  17. data/lib/okf/version.rb +1 -1
  18. data/lib/okf.rb +1 -0
  19. metadata +5 -28
  20. data/.okf/capabilities/agent-skill.md +0 -46
  21. data/.okf/capabilities/graph-server.md +0 -63
  22. data/.okf/capabilities/index.md +0 -20
  23. data/.okf/capabilities/library-api.md +0 -72
  24. data/.okf/capabilities/linter.md +0 -49
  25. data/.okf/capabilities/read-views.md +0 -84
  26. data/.okf/capabilities/validator.md +0 -40
  27. data/.okf/cli.md +0 -52
  28. data/.okf/design/core-shell-split.md +0 -58
  29. data/.okf/design/index.md +0 -10
  30. data/.okf/design/ruby-floor.md +0 -45
  31. data/.okf/design/runtime-dependencies.md +0 -44
  32. data/.okf/design/server-trust-boundary.md +0 -40
  33. data/.okf/format/citations.md +0 -33
  34. data/.okf/format/cross-links.md +0 -52
  35. data/.okf/format/frontmatter.md +0 -38
  36. data/.okf/format/index.md +0 -9
  37. data/.okf/format/okf-format.md +0 -43
  38. data/.okf/index.md +0 -18
  39. data/.okf/log.md +0 -10
  40. data/.okf/model/bundle.md +0 -38
  41. data/.okf/model/concept.md +0 -44
  42. data/.okf/model/graph.md +0 -44
  43. data/.okf/model/index.md +0 -8
  44. data/.okf/overview.md +0 -67
data/lib/okf/cli.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  module OKF
6
- # Command-line front end: `okf graph|validate|lint|loose|index|catalog|files|tags|types|stats|server <dir>`.
6
+ # Command-line front end: `okf graph|validate|lint|loose|search|index|catalog|files|tags|types|stats|server <dir>`.
7
7
  # This is the
8
8
  # only layer that parses argv, prints, writes files, and decides exit codes — the
9
9
  # lib classes below it just return data. Streams are injectable for testing.
@@ -47,6 +47,7 @@ module OKF
47
47
  when "validate" then validate(argv)
48
48
  when "lint" then lint(argv)
49
49
  when "loose" then loose(argv)
50
+ when "search" then search(argv)
50
51
  when "index" then index(argv)
51
52
  when "catalog" then catalog(argv)
52
53
  when "files" then files(argv)
@@ -54,6 +55,7 @@ module OKF
54
55
  when "types" then types(argv)
55
56
  when "stats" then stats(argv)
56
57
  when "server" then server(argv)
58
+ when "render" then render(argv)
57
59
  when "skill" then skill(argv)
58
60
  when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
59
61
  when "help", "--help", "-h" then usage(@out); 0
@@ -131,6 +133,65 @@ module OKF
131
133
  0
132
134
  end
133
135
 
136
+ # Deterministic text retrieval — the browser page's search brought to the CLI
137
+ # and extended to bodies. Terms after the directory are ANDed case-insensitive
138
+ # substrings (Ruby regexps with --regexp); rows rank by where they hit (title >
139
+ # id > tags > type/description > body) and carry one bounded context snippet,
140
+ # so "which concept covers X?" costs a few rows, not a body read. Advisory
141
+ # read: exit 0 even with no matches. Deliberately not fuzzy — the consuming
142
+ # agent is the fuzzy layer.
143
+ def search(argv)
144
+ options = { json: false, regexp: false }
145
+ parser = OptionParser.new do |o|
146
+ o.banner = "Usage: okf search <bundle-dir> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
147
+ json_flags(o, options, "emit the matches as JSON")
148
+ projection_flags(o, options)
149
+ o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
150
+ o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
151
+ filter_flags(o, options, :type, :area, :tag)
152
+ end
153
+ dir = positional_dir(parser, argv) or return 2
154
+ terms = argv
155
+ if terms.empty?
156
+ @err.puts parser.banner
157
+ return 2
158
+ end
159
+
160
+ unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
161
+ return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
162
+
163
+ folder = OKF::Bundle::Folder.load(dir)
164
+ report_skipped(folder)
165
+ rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
166
+ keep = filter_ids(folder, options)
167
+ rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
168
+ return print_search_json(dir, terms, rows, options) if options[:json]
169
+
170
+ print_search(dir, terms, rows, folder.bundle.concepts.size)
171
+ 0
172
+ rescue RegexpError => e
173
+ usage_error("invalid pattern: #{e.message}")
174
+ end
175
+
176
+ def print_search(dir, terms, rows, total)
177
+ @out.puts "Search — #{dir} · #{terms.join(" ")} (#{counted(rows.size, total, "concepts")})"
178
+ if rows.empty?
179
+ @out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
180
+ return
181
+ end
182
+
183
+ width = rows.map { |row| row[:id].length }.max
184
+ rows.each do |row|
185
+ @out.puts
186
+ @out.puts " #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
187
+ @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
188
+ end
189
+ end
190
+
191
+ def print_search_json(dir, terms, rows, options)
192
+ emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
193
+ end
194
+
134
195
  def server(argv)
135
196
  require "okf/server/app"
136
197
 
@@ -159,6 +220,34 @@ module OKF
159
220
  @runner.call(app, options[:bind], options[:port])
160
221
  end
161
222
 
223
+ # The static counterpart to `server`: bake the whole bundle into one
224
+ # self-contained HTML file (bodies, catalog, index, logs baked in, no server
225
+ # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
226
+ def render(argv)
227
+ require "okf/server/app"
228
+
229
+ options = { output: nil, title: nil, link: nil, layout: "cose" }
230
+ parser = OptionParser.new do |o|
231
+ o.banner = "Usage: okf render <bundle-dir> [-o FILE] [--layout NAME] [-t title] [-l url]"
232
+ o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
233
+ o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
234
+ o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
235
+ o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
236
+ end
237
+ dir = positional_dir(parser, argv) or return 2
238
+
239
+ folder = OKF::Bundle::Folder.load(dir)
240
+ report_skipped(folder)
241
+ html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
242
+ if options[:output]
243
+ File.write(options[:output], html)
244
+ @out.puts "wrote #{folder.graph(minimal: true).nodes.size} concepts to #{options[:output]}"
245
+ else
246
+ @out.print html
247
+ end
248
+ 0
249
+ end
250
+
162
251
  def graph(argv)
163
252
  options = { json: false, minimal: false, body: true }
164
253
  parser = OptionParser.new do |o|
@@ -801,13 +890,16 @@ module OKF
801
890
  # Emit a list view's JSON envelope with --fields/--except projection applied to
802
891
  # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
803
892
  # both flags at once, or a field name no item carries).
804
- def emit_list_json(dir, key, items, options)
893
+ def emit_list_json(dir, key, items, options, extra = {})
805
894
  return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
806
895
 
807
896
  unknown = unknown_fields(items, options)
808
897
  return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
809
898
 
810
- emit_json("bundle" => dir, "count" => items.size, key => project(items, options))
899
+ payload = { "bundle" => dir }.merge(extra)
900
+ payload["count"] = items.size
901
+ payload[key] = project(items, options)
902
+ emit_json(payload)
811
903
  0
812
904
  end
813
905
 
@@ -884,11 +976,13 @@ module OKF
884
976
 
885
977
  skill <dest> [--here] [--force] install the companion agent skill
886
978
  server <dir> [-p PORT] [--bind ADDR] [...] serve an interactive HTML graph
979
+ render <dir> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
887
980
 
888
981
  lint <dir> [--json] [--fail-on warn] [...] report curation-quality issues
889
982
  loose <dir> [--json] list files with no graph links, by folder
890
983
  validate <dir> [--json] check OKF v0.1 conformance
891
984
 
985
+ search <dir> <term…> [-e] [--in FIELDS] [...] find concepts by text or regexp, ranked
892
986
  index <dir> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
893
987
  stats <dir> [--json] bundle rollups (concepts, types, areas, links, tags)
894
988
  types <dir> [--json] [filters] list types with their concepts, by count
@@ -903,7 +997,7 @@ module OKF
903
997
  tags --by DIM regroups the tags per concept dimension — type or area — with
904
998
  within-group counts, the view for curating a tag vocabulary.
905
999
  --json emits compact JSON (the machine substrate); add --pretty to indent it.
906
- --fields / --except project the JSON to the properties you want (index/catalog/files).
1000
+ --fields / --except project the JSON to the properties you want (search/index/catalog/files).
907
1001
 
908
1002
  okf --version
909
1003
  USAGE
@@ -24,6 +24,12 @@ module OKF
24
24
  # tags, timestamp, status, area, dir, links_*} ] } (JSON)
25
25
  # GET /tags the tag index { tag => [id, …] } (JSON)
26
26
  # GET /types the type index { type => [id, …] } (JSON)
27
+ # GET /index the §6 progressive-disclosure map for the Index panel:
28
+ # { directories: [ …okf-index rows… ] } (JSON, from the
29
+ # boot snapshot — authored maps are structure)
30
+ # GET /log the §7 history for the Log panel: { logs: [ {path,
31
+ # dir, content} ] } (JSON; content read live from disk,
32
+ # like a body — the log is the file that changes most)
27
33
  class App
28
34
  def initialize(folder, title: nil, link: nil, layout: "cose")
29
35
  @folder = folder
@@ -43,10 +49,19 @@ module OKF
43
49
  when "/catalog" then respond_json(catalog)
44
50
  when "/tags" then respond_json(graph.tag_index)
45
51
  when "/types" then respond_json(graph.type_index)
52
+ when "/index" then respond_json(directory_index)
53
+ when "/log" then respond_json(logs)
46
54
  else not_found
47
55
  end
48
56
  end
49
57
 
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
63
+ end
64
+
50
65
  private
51
66
 
52
67
  # The minimal graph snapshot taken at boot — drives the page and the indexes.
@@ -63,10 +78,54 @@ module OKF
63
78
  { concepts: @folder.catalog }
64
79
  end
65
80
 
81
+ # The §6 map the Index panel renders — the same rows `okf index` prints,
82
+ # built by the pure OKF::Bundle#directory_index over the boot snapshot
83
+ # (authored index bodies are structure, read at load like the graph).
84
+ def directory_index
85
+ { directories: @folder.directory_index }
86
+ end
87
+
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.
91
+ 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)
100
+ end
101
+
66
102
  def page
67
103
  @page ||= Graph.new(graph, title: @title || @folder.name, link: @link, layout: @layout).render
68
104
  end
69
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) }
127
+ end
128
+
70
129
  def node_body(id)
71
130
  concept = concept_for(id)
72
131
  return not_found if concept.nil?