okf 1.0.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 +7 -0
- data/.okf/capabilities/agent-skill.md +46 -0
- data/.okf/capabilities/graph-server.md +60 -0
- data/.okf/capabilities/index.md +20 -0
- data/.okf/capabilities/library-api.md +67 -0
- data/.okf/capabilities/linter.md +49 -0
- data/.okf/capabilities/read-views.md +84 -0
- data/.okf/capabilities/validator.md +40 -0
- data/.okf/cli.md +52 -0
- data/.okf/design/core-shell-split.md +58 -0
- data/.okf/design/index.md +10 -0
- data/.okf/design/ruby-floor.md +45 -0
- data/.okf/design/runtime-dependencies.md +44 -0
- data/.okf/design/server-trust-boundary.md +35 -0
- data/.okf/format/citations.md +33 -0
- data/.okf/format/cross-links.md +52 -0
- data/.okf/format/frontmatter.md +38 -0
- data/.okf/format/index.md +9 -0
- data/.okf/format/okf-format.md +43 -0
- data/.okf/index.md +18 -0
- data/.okf/log.md +9 -0
- data/.okf/model/bundle.md +38 -0
- data/.okf/model/concept.md +44 -0
- data/.okf/model/graph.md +44 -0
- data/.okf/model/index.md +8 -0
- data/.okf/overview.md +66 -0
- data/CHANGELOG.md +54 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +276 -0
- data/exe/okf +6 -0
- data/lib/okf/bundle/folder.rb +94 -0
- data/lib/okf/bundle/graph.rb +118 -0
- data/lib/okf/bundle/linter/report.rb +56 -0
- data/lib/okf/bundle/linter.rb +416 -0
- data/lib/okf/bundle/reader.rb +60 -0
- data/lib/okf/bundle/validator/result.rb +35 -0
- data/lib/okf/bundle/validator.rb +131 -0
- data/lib/okf/bundle/writer.rb +137 -0
- data/lib/okf/bundle.rb +216 -0
- data/lib/okf/cli.rb +910 -0
- data/lib/okf/concept/file.rb +63 -0
- data/lib/okf/concept.rb +101 -0
- data/lib/okf/markdown/citations.rb +49 -0
- data/lib/okf/markdown/frontmatter.rb +55 -0
- data/lib/okf/markdown/links.rb +98 -0
- data/lib/okf/path.rb +34 -0
- data/lib/okf/server/app.rb +120 -0
- data/lib/okf/server/graph.rb +112 -0
- data/lib/okf/server/runner.rb +78 -0
- data/lib/okf/server/templates/graph.html.erb +803 -0
- data/lib/okf/skill/SKILL.md +133 -0
- data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
- data/lib/okf/skill/reference/SPEC.md +460 -0
- data/lib/okf/skill/reference/authoring.md +218 -0
- data/lib/okf/skill/reference/cli.md +196 -0
- data/lib/okf/skill/templates/concept.md +24 -0
- data/lib/okf/skill/templates/index.md +8 -0
- data/lib/okf/skill/templates/log.md +6 -0
- data/lib/okf/skill/templates/root-index.md +12 -0
- data/lib/okf/skill.rb +82 -0
- data/lib/okf/version.rb +5 -0
- data/lib/okf.rb +55 -0
- metadata +142 -0
data/lib/okf/cli.rb
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
# Command-line front end: `okf graph|validate|lint|loose|index|catalog|files|tags|types|stats|server <dir>`.
|
|
5
|
+
# This is the
|
|
6
|
+
# only layer that parses argv, prints, writes files, and decides exit codes — the
|
|
7
|
+
# lib classes below it just return data. Streams are injectable for testing.
|
|
8
|
+
#
|
|
9
|
+
# Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
|
|
10
|
+
class CLI
|
|
11
|
+
# Lint findings grouped for display, in category order.
|
|
12
|
+
LINT_CATEGORIES = {
|
|
13
|
+
"Reachability" => %i[orphan not_in_index disconnected_component unlinked],
|
|
14
|
+
"Backlog" => %i[missing_concept broken_index_entry],
|
|
15
|
+
"Completeness" => %i[stub missing_title missing_description missing_timestamp],
|
|
16
|
+
"Freshness" => %i[stale],
|
|
17
|
+
"Provenance" => %i[uncited_external broken_citation],
|
|
18
|
+
"Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
|
|
22
|
+
# tests can drive `server` without opening a socket; the runner loads here
|
|
23
|
+
# (not at require time) so `require "okf"` and a Rails mount of the server stay
|
|
24
|
+
# light.
|
|
25
|
+
WEBRICK = lambda do |app, host, port|
|
|
26
|
+
require "okf/server/runner"
|
|
27
|
+
OKF::Server::Runner.run(app, host: host, port: port)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
31
|
+
new(out: out, err: err).run(argv)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(out: $stdout, err: $stderr, runner: WEBRICK)
|
|
35
|
+
@out = out
|
|
36
|
+
@err = err
|
|
37
|
+
@runner = runner
|
|
38
|
+
@pretty = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run(argv)
|
|
42
|
+
argv = argv.dup
|
|
43
|
+
case (command = argv.shift)
|
|
44
|
+
when "graph" then graph(argv)
|
|
45
|
+
when "validate" then validate(argv)
|
|
46
|
+
when "lint" then lint(argv)
|
|
47
|
+
when "loose" then loose(argv)
|
|
48
|
+
when "index" then index(argv)
|
|
49
|
+
when "catalog" then catalog(argv)
|
|
50
|
+
when "files" then files(argv)
|
|
51
|
+
when "tags" then tags(argv)
|
|
52
|
+
when "types" then types(argv)
|
|
53
|
+
when "stats" then stats(argv)
|
|
54
|
+
when "server" then server(argv)
|
|
55
|
+
when "skill" then skill(argv)
|
|
56
|
+
when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
|
|
57
|
+
when "help", "--help", "-h" then usage(@out); 0
|
|
58
|
+
when nil then usage(@err); 2
|
|
59
|
+
else
|
|
60
|
+
@err.puts "okf: unknown command '#{command}'"
|
|
61
|
+
usage(@err)
|
|
62
|
+
2
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def validate(argv)
|
|
69
|
+
options = { json: false }
|
|
70
|
+
parser = OptionParser.new do |o|
|
|
71
|
+
o.banner = "Usage: okf validate <bundle-dir> [--json]"
|
|
72
|
+
json_flags(o, options, "emit a JSON report")
|
|
73
|
+
end
|
|
74
|
+
dir = positional_dir(parser, argv) or return 2
|
|
75
|
+
|
|
76
|
+
result = OKF::Bundle::Folder.load(dir).validate
|
|
77
|
+
options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
|
|
78
|
+
result.valid? ? 0 : 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def lint(argv)
|
|
82
|
+
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
|
|
83
|
+
parser = OptionParser.new do |o|
|
|
84
|
+
o.banner = "Usage: okf lint <bundle-dir> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
|
|
85
|
+
json_flags(o, options, "emit a JSON report")
|
|
86
|
+
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
87
|
+
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
|
|
88
|
+
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
89
|
+
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
90
|
+
o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
|
|
91
|
+
end
|
|
92
|
+
dir = positional_dir(parser, argv) or return 2
|
|
93
|
+
|
|
94
|
+
unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
|
|
95
|
+
unless unknown.empty?
|
|
96
|
+
@err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
|
|
97
|
+
return 2
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
stale_before = parse_stale_after(options[:stale_after])
|
|
101
|
+
if stale_before == :invalid
|
|
102
|
+
@err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
|
|
103
|
+
return 2
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
107
|
+
report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
|
|
108
|
+
note_skipped(report.stats[:skipped])
|
|
109
|
+
options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
|
|
110
|
+
options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# List the "loose" files — concepts with graph degree 0 (no cross-links in or
|
|
114
|
+
# out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
|
|
115
|
+
# for the common "which files float in the graph?" question. Advisory (exit 0):
|
|
116
|
+
# a terminal leaf can be loose by design. `--json` for a machine substrate.
|
|
117
|
+
def loose(argv)
|
|
118
|
+
options = { json: false }
|
|
119
|
+
parser = OptionParser.new do |o|
|
|
120
|
+
o.banner = "Usage: okf loose <bundle-dir> [--json]"
|
|
121
|
+
json_flags(o, options, "emit the loose files as JSON")
|
|
122
|
+
end
|
|
123
|
+
dir = positional_dir(parser, argv) or return 2
|
|
124
|
+
|
|
125
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
126
|
+
report_skipped(folder)
|
|
127
|
+
files = loose_files(folder.graph(minimal: true))
|
|
128
|
+
options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def server(argv)
|
|
133
|
+
require "okf/server/app"
|
|
134
|
+
|
|
135
|
+
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
|
|
136
|
+
parser = OptionParser.new do |o|
|
|
137
|
+
o.banner = "Usage: okf server <bundle-dir> [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
|
|
138
|
+
o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
|
|
139
|
+
o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
|
|
140
|
+
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
141
|
+
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
142
|
+
o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
143
|
+
end
|
|
144
|
+
dir = positional_dir(parser, argv) or return 2
|
|
145
|
+
|
|
146
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
147
|
+
report_skipped(folder)
|
|
148
|
+
run_server(folder, options)
|
|
149
|
+
0
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Build the Rack app and hand it to the runner (WEBrick by default, injected so
|
|
153
|
+
# tests drive this without a socket).
|
|
154
|
+
def run_server(folder, options)
|
|
155
|
+
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
|
|
156
|
+
@out.puts "serving #{folder.graph.nodes.size} concepts at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
157
|
+
@runner.call(app, options[:bind], options[:port])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def graph(argv)
|
|
161
|
+
options = { json: false, minimal: false, body: true }
|
|
162
|
+
parser = OptionParser.new do |o|
|
|
163
|
+
o.banner = "Usage: okf graph <bundle-dir> [--json] [--minimal] [--no-body]"
|
|
164
|
+
json_flags(o, options, "emit nodes and edges as JSON")
|
|
165
|
+
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
166
|
+
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
167
|
+
end
|
|
168
|
+
dir = positional_dir(parser, argv) or return 2
|
|
169
|
+
|
|
170
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
171
|
+
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
172
|
+
report_skipped(folder)
|
|
173
|
+
if options[:json]
|
|
174
|
+
payload = graph.to_h
|
|
175
|
+
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
176
|
+
emit_json(payload)
|
|
177
|
+
else
|
|
178
|
+
@out.puts "#{graph.nodes.size} concepts, #{graph.edges.size} links"
|
|
179
|
+
end
|
|
180
|
+
0
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The progressive-disclosure map (spec §6): every directory that holds concepts
|
|
184
|
+
# or carries an index.md, with its authored index body, a type/tag rollup, its
|
|
185
|
+
# child directories, and — for a directory with no index.md — the listing
|
|
186
|
+
# synthesized from the concepts there. The "orient before you read" view. `--area`
|
|
187
|
+
# is repeatable (one or many directories; `root` is the bundle root); `--no-body`
|
|
188
|
+
# drops the prose to a skeleton; advisory, exit 0.
|
|
189
|
+
def index(argv)
|
|
190
|
+
options = { json: false, body: true, areas: nil }
|
|
191
|
+
parser = OptionParser.new do |o|
|
|
192
|
+
o.banner = "Usage: okf index <bundle-dir> [--area AREA] [--no-body] [--json]"
|
|
193
|
+
json_flags(o, options, "emit the index map as JSON")
|
|
194
|
+
projection_flags(o, options)
|
|
195
|
+
o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
|
|
196
|
+
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
197
|
+
end
|
|
198
|
+
dir = positional_dir(parser, argv) or return 2
|
|
199
|
+
|
|
200
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
201
|
+
report_skipped(folder)
|
|
202
|
+
entries = folder.directory_index
|
|
203
|
+
selected = select_directories(entries, options[:areas])
|
|
204
|
+
if options[:json]
|
|
205
|
+
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
206
|
+
return print_index_map_json(dir, selected, options)
|
|
207
|
+
end
|
|
208
|
+
print_index_map(dir, selected, options[:body])
|
|
209
|
+
0
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Narrow the map to the named directories/areas — case-insensitive, `root`
|
|
213
|
+
# matching the bundle root (".") so no shell quoting is needed. No --area passed
|
|
214
|
+
# keeps the whole map.
|
|
215
|
+
def select_directories(entries, areas)
|
|
216
|
+
return entries if areas.nil? || areas.empty?
|
|
217
|
+
|
|
218
|
+
wanted = areas.map { |area| area.downcase == "root" ? "." : area.downcase }
|
|
219
|
+
entries.select { |entry| wanted.include?(entry[:dir].downcase) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def print_index_map(dir, entries, body)
|
|
223
|
+
noun = entries.size == 1 ? "directory" : "directories"
|
|
224
|
+
@out.puts "Index map — #{dir} (#{entries.size} #{noun})"
|
|
225
|
+
entries.each do |entry|
|
|
226
|
+
@out.puts
|
|
227
|
+
@out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
228
|
+
subdirs = entry[:subdirs]
|
|
229
|
+
@out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
|
|
230
|
+
if entry[:present]
|
|
231
|
+
print_index_body(entry[:body]) if body
|
|
232
|
+
else
|
|
233
|
+
print_synthesized_listing(entry[:listing])
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def index_dir_label(entry)
|
|
239
|
+
base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
|
|
240
|
+
entry[:present] ? base : "#{base} (no index.md)"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def index_dir_meta(entry)
|
|
244
|
+
count = "#{entry[:count]} #{entry[:count] == 1 ? "concept" : "concepts"}"
|
|
245
|
+
types = entry[:types].map { |type, n| "#{type.empty? ? "Untyped" : type} #{n}" }.join(", ")
|
|
246
|
+
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def print_index_body(body)
|
|
250
|
+
text = body.to_s.strip
|
|
251
|
+
return if text.empty?
|
|
252
|
+
|
|
253
|
+
text.each_line { |line| @out.puts " #{line.chomp}" }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def print_synthesized_listing(listing)
|
|
257
|
+
listing.each do |item|
|
|
258
|
+
suffix = item[:description].empty? ? "" : " — #{truncate(item[:description], 72)}"
|
|
259
|
+
@out.puts " • #{item[:title]}#{suffix}"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def print_index_map_json(dir, entries, options)
|
|
264
|
+
emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def index_map_entry_json(entry)
|
|
268
|
+
{
|
|
269
|
+
"dir" => entry[:dir], "index_path" => entry[:index_path],
|
|
270
|
+
"present" => entry[:present], "synthesized" => entry[:synthesized],
|
|
271
|
+
"count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
|
|
272
|
+
"subdirs" => entry[:subdirs], "body" => entry[:body],
|
|
273
|
+
"listing" => entry[:listing].map { |item| stringify(item) }
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The Catalog / Files / Tags / Stats views the server renders in the browser,
|
|
278
|
+
# reproduced on the CLI so an agent can read the same knowledge without one.
|
|
279
|
+
# Each prints a scannable human view by default and machine JSON with --json;
|
|
280
|
+
# all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
|
|
281
|
+
# and (with `types`) narrow through the same --type/--area/--tag filters the
|
|
282
|
+
# server UI offers, so browser and CLI can answer the same questions.
|
|
283
|
+
|
|
284
|
+
def catalog(argv)
|
|
285
|
+
options = { json: false }
|
|
286
|
+
parser = OptionParser.new do |o|
|
|
287
|
+
o.banner = "Usage: okf catalog <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
|
|
288
|
+
json_flags(o, options, "emit the catalog as JSON")
|
|
289
|
+
projection_flags(o, options)
|
|
290
|
+
filter_flags(o, options, :type, :area, :tag)
|
|
291
|
+
end
|
|
292
|
+
dir = positional_dir(parser, argv) or return 2
|
|
293
|
+
|
|
294
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
295
|
+
report_skipped(folder)
|
|
296
|
+
entries = folder.catalog
|
|
297
|
+
selected = filter_entries(entries, options)
|
|
298
|
+
return print_catalog_json(dir, selected, options) if options[:json]
|
|
299
|
+
|
|
300
|
+
print_catalog(dir, selected, entries.size)
|
|
301
|
+
0
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def files(argv)
|
|
305
|
+
options = { json: false }
|
|
306
|
+
parser = OptionParser.new do |o|
|
|
307
|
+
o.banner = "Usage: okf files <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
|
|
308
|
+
json_flags(o, options, "emit the file tree as JSON")
|
|
309
|
+
projection_flags(o, options)
|
|
310
|
+
filter_flags(o, options, :type, :area, :tag)
|
|
311
|
+
end
|
|
312
|
+
dir = positional_dir(parser, argv) or return 2
|
|
313
|
+
|
|
314
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
315
|
+
report_skipped(folder)
|
|
316
|
+
entries = folder.catalog
|
|
317
|
+
selected = filter_entries(entries, options)
|
|
318
|
+
return print_files_json(dir, selected, options) if options[:json]
|
|
319
|
+
|
|
320
|
+
print_files(dir, selected, entries.size)
|
|
321
|
+
0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def tags(argv)
|
|
325
|
+
options = { json: false, by: nil }
|
|
326
|
+
parser = OptionParser.new do |o|
|
|
327
|
+
o.banner = "Usage: okf tags <bundle-dir> [--by type|area] [--type T] [--area A] [--json]"
|
|
328
|
+
json_flags(o, options, "emit the tag index as JSON")
|
|
329
|
+
o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
|
|
330
|
+
filter_flags(o, options, :type, :area)
|
|
331
|
+
end
|
|
332
|
+
dir = positional_dir(parser, argv) or return 2
|
|
333
|
+
|
|
334
|
+
return grouped_tags(dir, options) if options[:by]
|
|
335
|
+
|
|
336
|
+
print_inverted_index(dir, "Tags", :tag, "tags", options)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def types(argv)
|
|
340
|
+
options = { json: false }
|
|
341
|
+
parser = OptionParser.new do |o|
|
|
342
|
+
o.banner = "Usage: okf types <bundle-dir> [--area A] [--tag T] [--json]"
|
|
343
|
+
json_flags(o, options, "emit the type index as JSON")
|
|
344
|
+
filter_flags(o, options, :area, :tag)
|
|
345
|
+
end
|
|
346
|
+
dir = positional_dir(parser, argv) or return 2
|
|
347
|
+
|
|
348
|
+
print_inverted_index(dir, "Types", :type, "types", options)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The shared back half of `tags` and `types`: load, narrow, print.
|
|
352
|
+
def print_inverted_index(dir, label, key, plural, options)
|
|
353
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
354
|
+
report_skipped(folder)
|
|
355
|
+
graph = folder.graph(minimal: true)
|
|
356
|
+
index = key == :tag ? graph.tag_index : graph.type_index
|
|
357
|
+
rows = index_rows(index, key, folder, options)
|
|
358
|
+
if options[:json]
|
|
359
|
+
print_index_json(dir, plural, key, rows)
|
|
360
|
+
else
|
|
361
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
362
|
+
print_index(dir, label, key, rows, titles)
|
|
363
|
+
end
|
|
364
|
+
0
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# `tags --by type|area`: the tag index re-cut per concept type or top-level
|
|
368
|
+
# area, with within-group counts — the curation view. A tag confined to one
|
|
369
|
+
# group at count 1 is scattered; one recurring across groups is connective.
|
|
370
|
+
# The --type/--area filters narrow the concepts first, then the grouping cuts.
|
|
371
|
+
def grouped_tags(dir, options)
|
|
372
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
373
|
+
report_skipped(folder)
|
|
374
|
+
graph = folder.graph(minimal: true)
|
|
375
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
376
|
+
groups = tag_groups(graph.tag_index, folder, options)
|
|
377
|
+
options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
|
|
378
|
+
0
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'.
|
|
382
|
+
# A tag carried in several groups appears in each, counted per group.
|
|
383
|
+
def tag_groups(tag_index, folder, options)
|
|
384
|
+
by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
|
|
385
|
+
groups = {}
|
|
386
|
+
tag_index.each do |tag, ids|
|
|
387
|
+
ids.each do |id|
|
|
388
|
+
entry = by_id[id]
|
|
389
|
+
next if entry.nil?
|
|
390
|
+
|
|
391
|
+
key = options[:by] == :type ? entry_type(entry) : entry[:area]
|
|
392
|
+
((groups[key] ||= {})[tag] ||= []) << id
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
groups.map do |key, tags|
|
|
396
|
+
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, concepts: ids } }
|
|
397
|
+
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
398
|
+
[ key, rows ]
|
|
399
|
+
end.sort_by(&:first)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
403
|
+
def entry_type(entry)
|
|
404
|
+
entry[:type].empty? ? "Untyped" : entry[:type]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def print_grouped_tags(dir, dim, groups, titles)
|
|
408
|
+
@out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
409
|
+
groups.each do |key, rows|
|
|
410
|
+
label = dim == :area && key != "(root)" ? "#{key}/" : key
|
|
411
|
+
@out.puts
|
|
412
|
+
@out.puts " #{label} (#{rows.size} tags)"
|
|
413
|
+
width = rows.map { |row| row[:tag].length }.max || 0
|
|
414
|
+
rows.each do |row|
|
|
415
|
+
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
416
|
+
@out.puts " #{row[:tag].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 76)}"
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def print_grouped_tags_json(dir, dim, groups)
|
|
422
|
+
emit_json(
|
|
423
|
+
"bundle" => dir, "count" => distinct_tags(groups), "by" => dim.to_s,
|
|
424
|
+
"groups" => groups.map do |key, rows|
|
|
425
|
+
{ dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
|
|
426
|
+
end
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def distinct_tags(groups)
|
|
431
|
+
groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def stats(argv)
|
|
435
|
+
options = { json: false }
|
|
436
|
+
parser = OptionParser.new do |o|
|
|
437
|
+
o.banner = "Usage: okf stats <bundle-dir> [--json]"
|
|
438
|
+
json_flags(o, options, "emit the stats as JSON")
|
|
439
|
+
end
|
|
440
|
+
dir = positional_dir(parser, argv) or return 2
|
|
441
|
+
|
|
442
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
443
|
+
report_skipped(folder)
|
|
444
|
+
stats = bundle_stats(folder)
|
|
445
|
+
options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
|
|
446
|
+
0
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Bundle-level rollups derived from the catalog and the graph indexes.
|
|
450
|
+
def bundle_stats(folder)
|
|
451
|
+
graph = folder.graph(minimal: true)
|
|
452
|
+
entries = folder.catalog
|
|
453
|
+
by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
454
|
+
by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
455
|
+
{
|
|
456
|
+
concepts: entries.size,
|
|
457
|
+
areas: by_area.size,
|
|
458
|
+
types: by_type.size,
|
|
459
|
+
cross_links: graph.edges.size,
|
|
460
|
+
tags: graph.tag_index.size,
|
|
461
|
+
by_type: by_type,
|
|
462
|
+
by_area: by_area
|
|
463
|
+
}
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# ── the read views' shared --type/--area/--tag narrowing ──
|
|
467
|
+
# Each view takes the filters orthogonal to it (tags can't filter by tag).
|
|
468
|
+
# Matching is case-insensitive and exact; a concept at the bundle root lives in
|
|
469
|
+
# the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
|
|
470
|
+
|
|
471
|
+
# The --json / --pretty pair every emitting verb shares. --json is the compact
|
|
472
|
+
# machine substrate (the default JSON form, aligned with the server); --pretty
|
|
473
|
+
# indents it for a human and implies --json. Both route through emit_json.
|
|
474
|
+
def json_flags(parser, options, desc)
|
|
475
|
+
parser.on("--json", desc) { options[:json] = true }
|
|
476
|
+
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
480
|
+
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
481
|
+
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
482
|
+
# in a list view (catalog, files, index). Names are the JSON keys, matched
|
|
483
|
+
# case-insensitively.
|
|
484
|
+
def projection_flags(parser, options)
|
|
485
|
+
parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
|
|
486
|
+
parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def filter_flags(parser, options, *keys)
|
|
490
|
+
parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
|
|
491
|
+
parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
|
|
492
|
+
parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def filter_entries(entries, options)
|
|
496
|
+
entries.select do |entry|
|
|
497
|
+
(options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
|
|
498
|
+
(options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
|
|
499
|
+
(options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def fold(value)
|
|
504
|
+
value.to_s.downcase
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def fold_area(value)
|
|
508
|
+
folded = fold(value)
|
|
509
|
+
folded == "root" ? "(root)" : folded
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Turn an inverted index ({ value => [id, …] }) into display rows ordered by
|
|
513
|
+
# count, narrowed to the concepts the active filters select; rows the narrowing
|
|
514
|
+
# empties drop. With no filters the index passes through whole.
|
|
515
|
+
def index_rows(index, key, folder, options)
|
|
516
|
+
keep = filter_ids(folder, options)
|
|
517
|
+
index.each_with_object([]) do |(value, ids), rows|
|
|
518
|
+
ids = ids.select { |id| keep.include?(id) } unless keep.nil?
|
|
519
|
+
rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
|
|
520
|
+
end.sort_by { |row| [ -row[:count], row[key] ] }
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# The ids the filters select, resolved through the catalog metadata — or nil
|
|
524
|
+
# when no filter is active, meaning keep everything.
|
|
525
|
+
def filter_ids(folder, options)
|
|
526
|
+
return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
|
|
527
|
+
|
|
528
|
+
filter_entries(folder.catalog, options).map { |entry| entry[:id] }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Install this gem's companion agent skill into a destination directory. The
|
|
532
|
+
# destination is required (no magic default) so the user always decides where
|
|
533
|
+
# their agent picks the skill up. By default the skill lands in a skills/okf/
|
|
534
|
+
# folder under it — point at a project or skills dir (.claude, .agents/skills)
|
|
535
|
+
# and it settles in its own folder, never loose among the others — so the
|
|
536
|
+
# resolved path is echoed back. --here installs straight into <dest-dir>.
|
|
537
|
+
def skill(argv)
|
|
538
|
+
options = { force: false, nest: true }
|
|
539
|
+
parser = OptionParser.new do |o|
|
|
540
|
+
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
541
|
+
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
542
|
+
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
543
|
+
end
|
|
544
|
+
parser.parse!(argv)
|
|
545
|
+
dest = argv.shift
|
|
546
|
+
if dest.nil?
|
|
547
|
+
@err.puts parser.banner
|
|
548
|
+
return 2
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
|
|
552
|
+
files = skill.install
|
|
553
|
+
@out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
|
|
554
|
+
files.each { |f| @out.puts " #{f}" }
|
|
555
|
+
@out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
|
|
556
|
+
0
|
|
557
|
+
rescue OptionParser::ParseError => e
|
|
558
|
+
@err.puts e.message
|
|
559
|
+
2
|
|
560
|
+
rescue OKF::Skill::Error => e
|
|
561
|
+
@err.puts "error: #{e.message}"
|
|
562
|
+
2
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# §9 best-effort: the graph is built from concepts that parse. Surface any that
|
|
566
|
+
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
567
|
+
# than dropping them silently.
|
|
568
|
+
def report_skipped(folder)
|
|
569
|
+
note_skipped(folder.bundle.unparseable.size)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def note_skipped(count)
|
|
573
|
+
return if count.nil? || count <= 0
|
|
574
|
+
|
|
575
|
+
@err.puts "note: skipped #{count} file(s) with invalid frontmatter (run `okf validate` for details)"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
579
|
+
# Time so the pure Linter never reads the clock. nil when unset, :invalid on a
|
|
580
|
+
# bad value.
|
|
581
|
+
def parse_stale_after(value)
|
|
582
|
+
return nil if value.nil?
|
|
583
|
+
|
|
584
|
+
if (match = value.match(/\A(\d+)([dw])\z/))
|
|
585
|
+
days = match[1].to_i * (match[2] == "w" ? 7 : 1)
|
|
586
|
+
Time.now - (days * 86_400)
|
|
587
|
+
else
|
|
588
|
+
Date.iso8601(value).to_time
|
|
589
|
+
end
|
|
590
|
+
rescue ArgumentError
|
|
591
|
+
:invalid
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Parse options, then require a single existing-directory positional argument.
|
|
595
|
+
# Returns the directory, or nil (after reporting) so the caller returns 2.
|
|
596
|
+
def positional_dir(parser, argv)
|
|
597
|
+
parser.parse!(argv)
|
|
598
|
+
dir = argv.shift
|
|
599
|
+
if dir.nil?
|
|
600
|
+
@err.puts parser.banner
|
|
601
|
+
return nil
|
|
602
|
+
end
|
|
603
|
+
unless File.directory?(dir)
|
|
604
|
+
@err.puts "error: #{dir} is not a directory"
|
|
605
|
+
return nil
|
|
606
|
+
end
|
|
607
|
+
dir
|
|
608
|
+
rescue OptionParser::ParseError => e
|
|
609
|
+
@err.puts e.message
|
|
610
|
+
nil
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def print_validation(dir, result)
|
|
614
|
+
counts = result.counts
|
|
615
|
+
@out.puts "OKF v0.1 conformance — #{dir}"
|
|
616
|
+
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
617
|
+
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
618
|
+
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
619
|
+
if result.valid? && result.warnings.empty?
|
|
620
|
+
@out.puts " #{paint("✓ conformant — no issues", 32)}"
|
|
621
|
+
elsif result.valid?
|
|
622
|
+
@out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
|
|
623
|
+
else
|
|
624
|
+
@out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def print_validation_json(dir, result)
|
|
629
|
+
emit_json(
|
|
630
|
+
"bundle" => dir,
|
|
631
|
+
"conformant" => result.valid?,
|
|
632
|
+
"counts" => result.counts,
|
|
633
|
+
"errors" => result.errors,
|
|
634
|
+
"warnings" => result.warnings
|
|
635
|
+
)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def print_lint(dir, report)
|
|
639
|
+
stats = report.stats
|
|
640
|
+
@out.puts "OKF lint — #{dir}"
|
|
641
|
+
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
642
|
+
summary = lint_summary(stats)
|
|
643
|
+
@out.puts " #{summary}" unless summary.empty?
|
|
644
|
+
|
|
645
|
+
LINT_CATEGORIES.each do |name, checks|
|
|
646
|
+
findings = report.findings.select { |finding| checks.include?(finding[:check]) }
|
|
647
|
+
next if findings.empty?
|
|
648
|
+
|
|
649
|
+
@out.puts
|
|
650
|
+
@out.puts " #{name}"
|
|
651
|
+
findings.each do |finding|
|
|
652
|
+
@out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
@out.puts
|
|
657
|
+
@out.puts " #{lint_verdict(report)}"
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def print_lint_json(dir, report)
|
|
661
|
+
emit_json(
|
|
662
|
+
"bundle" => dir,
|
|
663
|
+
"healthy" => report.healthy?,
|
|
664
|
+
"stats" => report.stats,
|
|
665
|
+
"findings" => report.findings
|
|
666
|
+
)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
670
|
+
# `unlinked` check reports, resolved to titles/folders for display.
|
|
671
|
+
def loose_files(graph)
|
|
672
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
673
|
+
graph.unlinked_ids
|
|
674
|
+
.map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
|
|
675
|
+
.sort_by { |file| file[:id] }
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def print_loose(dir, files)
|
|
679
|
+
@out.puts "Loose files — #{dir} (#{files.size})"
|
|
680
|
+
if files.empty?
|
|
681
|
+
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
682
|
+
return
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
|
|
686
|
+
width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
|
|
687
|
+
@out.puts
|
|
688
|
+
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
689
|
+
group.each do |file|
|
|
690
|
+
@out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def print_loose_json(dir, files)
|
|
696
|
+
emit_json(
|
|
697
|
+
"bundle" => dir,
|
|
698
|
+
"count" => files.size,
|
|
699
|
+
"loose" => files.map { |file| stringify(file) }
|
|
700
|
+
)
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def print_catalog(dir, entries, total)
|
|
704
|
+
@out.puts "Catalog — #{dir} (#{counted(entries.size, total, "concepts")})"
|
|
705
|
+
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
706
|
+
@out.puts
|
|
707
|
+
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
708
|
+
group.each do |entry|
|
|
709
|
+
links = entry[:links_out] + entry[:links_in]
|
|
710
|
+
meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
|
|
711
|
+
@out.puts " #{entry[:title]} · #{meta}"
|
|
712
|
+
@out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def print_catalog_json(dir, entries, options)
|
|
718
|
+
emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def print_files(dir, entries, total)
|
|
722
|
+
@out.puts "Files — #{dir} (#{counted(entries.size, total, "files")})"
|
|
723
|
+
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
724
|
+
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
725
|
+
@out.puts
|
|
726
|
+
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
727
|
+
group.each do |entry|
|
|
728
|
+
@out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def print_files_json(dir, entries, options)
|
|
734
|
+
files = entries.map do |entry|
|
|
735
|
+
{ "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
|
|
736
|
+
"description" => entry[:description] }
|
|
737
|
+
end
|
|
738
|
+
emit_list_json(dir, "files", files, options)
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def print_index(dir, label, key, rows, titles)
|
|
742
|
+
@out.puts "#{label} — #{dir} (#{rows.size} distinct)"
|
|
743
|
+
@out.puts
|
|
744
|
+
width = rows.map { |row| row[key].length }.max || 0
|
|
745
|
+
rows.each do |row|
|
|
746
|
+
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
747
|
+
@out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def print_index_json(dir, plural, key, rows)
|
|
752
|
+
emit_json("bundle" => dir, "count" => rows.size, plural => index_rows_json(key, rows))
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def index_rows_json(key, rows)
|
|
756
|
+
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def counted(size, total, noun)
|
|
760
|
+
size == total ? "#{size} #{noun}" : "#{size} of #{total} #{noun}"
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def print_stats(dir, stats)
|
|
764
|
+
@out.puts "Stats — #{dir}"
|
|
765
|
+
@out.puts
|
|
766
|
+
@out.puts " concepts #{stats[:concepts]}"
|
|
767
|
+
@out.puts " areas #{stats[:areas]}"
|
|
768
|
+
@out.puts " concept types #{stats[:types]}"
|
|
769
|
+
@out.puts " cross-links #{stats[:cross_links]}"
|
|
770
|
+
@out.puts " distinct tags #{stats[:tags]}"
|
|
771
|
+
print_stat_breakdown("By type", stats[:by_type])
|
|
772
|
+
print_stat_breakdown("By area", stats[:by_area])
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def print_stat_breakdown(title, counts)
|
|
776
|
+
return if counts.empty?
|
|
777
|
+
|
|
778
|
+
width = counts.keys.map(&:length).max
|
|
779
|
+
@out.puts
|
|
780
|
+
@out.puts " #{title}"
|
|
781
|
+
counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def print_stats_json(dir, stats)
|
|
785
|
+
emit_json(
|
|
786
|
+
"bundle" => dir, "concepts" => stats[:concepts], "areas" => stats[:areas],
|
|
787
|
+
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
788
|
+
"by_type" => stats[:by_type], "by_area" => stats[:by_area]
|
|
789
|
+
)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
793
|
+
# agent consumes; --pretty indents it for a human. JSON semantics are identical
|
|
794
|
+
# either way, so a parser never cares which was emitted.
|
|
795
|
+
def emit_json(payload)
|
|
796
|
+
@out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
800
|
+
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
801
|
+
# both flags at once, or a field name no item carries).
|
|
802
|
+
def emit_list_json(dir, key, items, options)
|
|
803
|
+
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
804
|
+
|
|
805
|
+
unknown = unknown_fields(items, options)
|
|
806
|
+
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
|
|
807
|
+
|
|
808
|
+
emit_json("bundle" => dir, "count" => items.size, key => project(items, options))
|
|
809
|
+
0
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Keep only --fields (allowlist) or drop --except (denylist) from each item's
|
|
813
|
+
# top-level properties; unset flags pass the items through whole.
|
|
814
|
+
def project(items, options)
|
|
815
|
+
return items if options[:fields].nil? && options[:except].nil?
|
|
816
|
+
|
|
817
|
+
fields = options[:fields]&.map(&:downcase)
|
|
818
|
+
except = options[:except]&.map(&:downcase)
|
|
819
|
+
items.map do |item|
|
|
820
|
+
fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def available_fields(items)
|
|
825
|
+
items.first ? items.first.keys.map(&:to_s) : []
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
829
|
+
# matching how lint rejects unknown check names.
|
|
830
|
+
def unknown_fields(items, options)
|
|
831
|
+
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
832
|
+
return [] if requested.empty? || items.empty?
|
|
833
|
+
|
|
834
|
+
known = available_fields(items).map(&:downcase)
|
|
835
|
+
requested.reject { |field| known.include?(field) }.uniq
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def usage_error(message)
|
|
839
|
+
@err.puts "error: #{message}"
|
|
840
|
+
2
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def stringify(hash)
|
|
844
|
+
hash.map { |key, value| [ key.to_s, value ] }.to_h
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def truncate(str, max)
|
|
848
|
+
str.length > max ? "#{str[0, max - 1]}…" : str
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def lint_summary(stats)
|
|
852
|
+
parts = []
|
|
853
|
+
hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
|
|
854
|
+
types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
|
|
855
|
+
parts << "hubs: #{hubs}" unless hubs.empty?
|
|
856
|
+
parts << "types: #{types}" unless types.empty?
|
|
857
|
+
parts.join(" ")
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def lint_glyph(finding)
|
|
861
|
+
finding[:severity] == :warn ? paint("! warn", 33) : "· info"
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def lint_verdict(report)
|
|
865
|
+
warnings = report.warnings.size
|
|
866
|
+
infos = report.info.size
|
|
867
|
+
return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
|
|
868
|
+
|
|
869
|
+
marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
|
|
870
|
+
"#{marker} #{warnings} warn, #{infos} info"
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def paint(text, code)
|
|
874
|
+
return text unless @out.respond_to?(:tty?) && @out.tty?
|
|
875
|
+
|
|
876
|
+
"\e[#{code}m#{text}\e[0m"
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def usage(io)
|
|
880
|
+
io.puts <<~USAGE
|
|
881
|
+
okf <command> [options]
|
|
882
|
+
|
|
883
|
+
skill <dest> [--here] [--force] install the companion agent skill
|
|
884
|
+
server <dir> [-p PORT] [--bind ADDR] [...] serve an interactive HTML graph
|
|
885
|
+
|
|
886
|
+
lint <dir> [--json] [--fail-on warn] [...] report curation-quality issues
|
|
887
|
+
loose <dir> [--json] list files with no graph links, by folder
|
|
888
|
+
validate <dir> [--json] check OKF v0.1 conformance
|
|
889
|
+
|
|
890
|
+
index <dir> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
|
|
891
|
+
stats <dir> [--json] bundle rollups (concepts, types, areas, links, tags)
|
|
892
|
+
types <dir> [--json] [filters] list types with their concepts, by count
|
|
893
|
+
tags <dir> [--json] [--by DIM] [filters] list tags with their concepts, by count
|
|
894
|
+
files <dir> [--json] [filters] list files with titles, by folder
|
|
895
|
+
catalog <dir> [--json] [filters] list concepts with metadata, by area
|
|
896
|
+
|
|
897
|
+
graph <dir> [--json] [--minimal] [--no-body] print the knowledge graph
|
|
898
|
+
|
|
899
|
+
[filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
|
|
900
|
+
(each view takes the ones orthogonal to it; matching is case-insensitive).
|
|
901
|
+
tags --by DIM regroups the tags per concept dimension — type or area — with
|
|
902
|
+
within-group counts, the view for curating a tag vocabulary.
|
|
903
|
+
--json emits compact JSON (the machine substrate); add --pretty to indent it.
|
|
904
|
+
--fields / --except project the JSON to the properties you want (index/catalog/files).
|
|
905
|
+
|
|
906
|
+
okf --version
|
|
907
|
+
USAGE
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
end
|