okf-pro 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/contract/containment-directions.md +107 -0
- data/.okf/contract/exit-codes.md +42 -0
- data/.okf/contract/gates-only-at-the-hook-door.md +39 -0
- data/.okf/contract/index.md +10 -0
- data/.okf/contract/silent-skips.md +78 -0
- data/.okf/contract/telemetry-does-not-lie.md +218 -0
- data/.okf/contract/the-contract.md +43 -0
- data/.okf/design/a-comment-is-not-an-implementation.md +85 -0
- data/.okf/design/a-rule-you-can-walk-past.md +68 -0
- data/.okf/design/derivation-that-writes.md +161 -0
- data/.okf/design/failure-modes.md +43 -0
- data/.okf/design/index.md +18 -0
- data/.okf/design/lineage.md +95 -0
- data/.okf/design/structure-laws.md +46 -0
- data/.okf/design/the-residue.md +44 -0
- data/.okf/design/three-laws.md +71 -0
- data/.okf/design/three-pillars.md +48 -0
- data/.okf/index.md +31 -0
- data/.okf/log.md +74 -0
- data/.okf/scaffold/collisions-and-refusals.md +76 -0
- data/.okf/scaffold/index.md +8 -0
- data/.okf/scaffold/no-date-ships.md +41 -0
- data/.okf/scaffold/ownership-not-subject.md +63 -0
- data/.okf/scaffold/the-adopters-manual.md +114 -0
- data/.okf/seam/bundler-scoping.md +45 -0
- data/.okf/seam/identity-not-existence.md +67 -0
- data/.okf/seam/index.md +8 -0
- data/.okf/seam/the-wrapper.md +42 -0
- data/.okf/seam/three-fail-opens.md +44 -0
- data/.okf/testing/drills-over-units.md +41 -0
- data/.okf/testing/fixture-is-a-client.md +35 -0
- data/.okf/testing/index.md +4 -0
- data/.okf/trust/index.md +7 -0
- data/.okf/trust/read-owed-rule.md +45 -0
- data/.okf/trust/scalar-verified.md +48 -0
- data/CHANGELOG.md +187 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +383 -0
- data/lib/okf/plugin.rb +148 -0
- data/lib/okf/pro/attestation.rb +62 -0
- data/lib/okf/pro/audit.rb +161 -0
- data/lib/okf/pro/board/edit.rb +201 -0
- data/lib/okf/pro/board.rb +291 -0
- data/lib/okf/pro/budget.rb +144 -0
- data/lib/okf/pro/bundle_root.rb +241 -0
- data/lib/okf/pro/cli.rb +914 -0
- data/lib/okf/pro/closing.rb +206 -0
- data/lib/okf/pro/conformance.rb +127 -0
- data/lib/okf/pro/conserve.rb +94 -0
- data/lib/okf/pro/event.rb +95 -0
- data/lib/okf/pro/friction.rb +262 -0
- data/lib/okf/pro/guards.rb +92 -0
- data/lib/okf/pro/log/edit.rb +68 -0
- data/lib/okf/pro/log.rb +162 -0
- data/lib/okf/pro/pairing.rb +271 -0
- data/lib/okf/pro/reconcile.rb +76 -0
- data/lib/okf/pro/records.rb +90 -0
- data/lib/okf/pro/scaffold.rb +258 -0
- data/lib/okf/pro/shell_guard.rb +164 -0
- data/lib/okf/pro/snapshot.rb +156 -0
- data/lib/okf/pro/state.rb +174 -0
- data/lib/okf/pro/target.rb +91 -0
- data/lib/okf/pro/template/gem/.claude/hooks/run +119 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/SKILL.md +233 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/attribution.md +46 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/board.md +55 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/closing.md +21 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/frontmatter.md +73 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/okf-rules.md +25 -0
- data/lib/okf/pro/template/gem/.githooks/pre-commit +68 -0
- data/lib/okf/pro/template/gem/.github/workflows/okf-pro.yml +69 -0
- data/lib/okf/pro/template/seed/.claude/settings.json +24 -0
- data/lib/okf/pro/template/seed/.okf/areas/corpus.md +73 -0
- data/lib/okf/pro/template/seed/.okf/areas/index.md +6 -0
- data/lib/okf/pro/template/seed/.okf/board.md +21 -0
- data/lib/okf/pro/template/seed/.okf/glossary/index.md +13 -0
- data/lib/okf/pro/template/seed/.okf/index.md +30 -0
- data/lib/okf/pro/template/seed/.okf/journal/index.md +11 -0
- data/lib/okf/pro/template/seed/.okf/learnings/index.md +13 -0
- data/lib/okf/pro/template/seed/.okf/log.md +10 -0
- data/lib/okf/pro/template/seed/.okf/projects/index.md +23 -0
- data/lib/okf/pro/template/seed/.okf/reference/index.md +16 -0
- data/lib/okf/pro/template/seed/.okf/roadmap.md +33 -0
- data/lib/okf/pro/template/seed/.tmp/.gitkeep +0 -0
- data/lib/okf/pro/template/seed/CLAUDE.md +61 -0
- data/lib/okf/pro/template/seed/README.md +488 -0
- data/lib/okf/pro/template/seed/gitignore +17 -0
- data/lib/okf/pro/version.rb +7 -0
- data/lib/okf/pro/writes.rb +560 -0
- data/lib/okf/pro.rb +198 -0
- metadata +169 -0
data/lib/okf/pro/cli.rb
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hand-rolled where okf's Command already has `json_flags`/`emit_json`, and
|
|
4
|
+
# deliberately: those are PRIVATE instance methods on a class this module is not
|
|
5
|
+
# — `OKF::Pro::CLI` is a module, and the suite drives `CLI.run` directly rather
|
|
6
|
+
# than through `okf/plugin.rb`, so a parser inherited from the kernel would be
|
|
7
|
+
# untested at the one door every test goes through. The same argument the
|
|
8
|
+
# duplicated `help_rows` already carries.
|
|
9
|
+
require "optparse"
|
|
10
|
+
|
|
11
|
+
module OKF
|
|
12
|
+
module Pro
|
|
13
|
+
# Dispatch, and the exit codes the hook protocol reads.
|
|
14
|
+
#
|
|
15
|
+
# Every check has the same signature — event in, messages out, empty means
|
|
16
|
+
# pass — so this table is the only place a check name is bound to behaviour,
|
|
17
|
+
# and the only place an exit code is chosen. A drill runs exactly what a
|
|
18
|
+
# session runs because both arrive here.
|
|
19
|
+
module CLI
|
|
20
|
+
CHECKS = {
|
|
21
|
+
"guard-verified" => ->(event) { Guards.guard_verified(event) },
|
|
22
|
+
"journal-guard" => ->(event) { Guards.journal_guard(event) },
|
|
23
|
+
"shell-guard" => ->(event) { ShellGuard.check(event) },
|
|
24
|
+
"check-okf" => ->(event) { Conformance.check(Target.for(event)) },
|
|
25
|
+
"cap-check" => ->(event) { Budget.cap_check(Target.for(event)) },
|
|
26
|
+
"reconcile-search" => ->(event) { Reconcile.search(Target.for(event), event) },
|
|
27
|
+
"post-edit" => ->(event) { CLI.post_edit(event) },
|
|
28
|
+
"stop-gate" => ->(event) { Closing.stop_gate(event) }
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Every name this module answers to. The hook door accepts a strictly
|
|
32
|
+
# narrower set — CHECKS.keys plus session-context — enforced one layer up,
|
|
33
|
+
# in OKF::CLI::Pro, because `run` dispatches the CI verbs off the same
|
|
34
|
+
# first element: a settings.json typo spelling `hook audit` would otherwise
|
|
35
|
+
# install a gate that reads no stdin, never blocks, and reports clean.
|
|
36
|
+
# The generator's verbs. They take a destination rather than a bundle, so
|
|
37
|
+
# they do not go through `dir_argument` — `setup` into an empty directory
|
|
38
|
+
# is the whole point, and refusing one that holds no bundle would refuse
|
|
39
|
+
# every first run.
|
|
40
|
+
SCAFFOLD = %w[setup upgrade skill].freeze
|
|
41
|
+
|
|
42
|
+
# The readers: one call answers what nine calls answered. No new logic —
|
|
43
|
+
# every aggregation they print already existed and was consumed only by a
|
|
44
|
+
# gate, which is why an agent working in a seeded bundle rediscovered the
|
|
45
|
+
# board by reading raw markdown while the stop gate was computing it.
|
|
46
|
+
READERS = %w[audit records snapshot unverified state board friction].freeze
|
|
47
|
+
|
|
48
|
+
# The writers: the shapes with exactly one correct form. Each one is
|
|
49
|
+
# additive and targeted, and each refuses through `Conserve` rather than
|
|
50
|
+
# promising to be careful — see `writes.rb` for the contract.
|
|
51
|
+
WRITERS = %w[capture promote demote journal close].freeze
|
|
52
|
+
|
|
53
|
+
NAMES = (CHECKS.keys + [ "session-context" ] + READERS + WRITERS + SCAFFOLD).freeze
|
|
54
|
+
|
|
55
|
+
# The names the hook door accepts, and the only ones. `run` dispatches the
|
|
56
|
+
# CI verbs off the same first element, so without this the adapter would
|
|
57
|
+
# forward `hook audit` into a verb that reads no stdin and cannot block.
|
|
58
|
+
HOOK_NAMES = (CHECKS.keys + [ "session-context" ]).freeze
|
|
59
|
+
|
|
60
|
+
# Written to stderr immediately before a check runs, and read by the
|
|
61
|
+
# scaffold's `.claude/hooks/run`. It is the whole of the wrapper's identity
|
|
62
|
+
# proof: a stray `okf` on PATH that exits 0 is indistinguishable from a
|
|
63
|
+
# clean gate by status alone, and that is a gate silently switched off.
|
|
64
|
+
# The wrapper strips this line before passing stderr on.
|
|
65
|
+
#
|
|
66
|
+
# Emitted here rather than at the door, so that its presence means the
|
|
67
|
+
# check was actually reached — not merely that the plugin loaded. The
|
|
68
|
+
# prototype proved the difference: its `okf pro contract` handshake
|
|
69
|
+
# answered fine while the library was missing.
|
|
70
|
+
MARKER = "okf-pro-enforcer v1"
|
|
71
|
+
|
|
72
|
+
# What `dir_argument` returns instead of a path. Distinct from nil, which
|
|
73
|
+
# is the legitimate "no argument given, use the working directory".
|
|
74
|
+
REFUSED = :refused
|
|
75
|
+
|
|
76
|
+
# The command list, and the single place it is described. `okf help`
|
|
77
|
+
# prints a second copy through OKF::CLI::Pro.help_rows, and it has to:
|
|
78
|
+
# reading this one would make every `okf help` load the whole library,
|
|
79
|
+
# which is the cost deferring the require exists to avoid. The two are
|
|
80
|
+
# joined by a test instead — the arrangement the closure grammar and the
|
|
81
|
+
# dormancy window already use.
|
|
82
|
+
USAGE = [
|
|
83
|
+
[ "setup", "[DIR]", "create or complete an agent's brain in DIR (default .)" ],
|
|
84
|
+
[ "upgrade", "[DIR]", "rewrite the gem-owned governance files; stage the rest" ],
|
|
85
|
+
[ "state", "[DIR]", "what is on the board, in one call — add --full for the corpus" ],
|
|
86
|
+
[ "board", "[DIR]", "one row per board line: section, dates, age, links" ],
|
|
87
|
+
[ "capture", "TEXT", "append a dated Inbox line" ],
|
|
88
|
+
[ "promote", "SEL", "Inbox or Backlog to In flight, refusing over the cap" ],
|
|
89
|
+
[ "demote", "SEL", "In flight back to Backlog" ],
|
|
90
|
+
[ "journal", "open", "create today's journal day and index it" ],
|
|
91
|
+
[ "close", "SLUG", "the three mechanical closing moves for a project" ],
|
|
92
|
+
[ "audit", "[DIR]", "every invariant at once — the CI door" ],
|
|
93
|
+
[ "records", "[DIR]", "does the staged commit rewrite a past journal day?" ],
|
|
94
|
+
[ "snapshot", "[DIR]", "compute the day's counter line (prints, never writes)" ],
|
|
95
|
+
[ "unverified", "[DIR]", "generated concepts still awaiting the owner's read" ],
|
|
96
|
+
[ "friction", "[DIR]", "what was done by hand that a verb could do" ],
|
|
97
|
+
[ "skill", "DEST", "(re)install okf-pro's agent skill on its own" ],
|
|
98
|
+
[ "hook", "CHECK", "run one gate against a hook event on stdin" ]
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
101
|
+
# Which flags each verb accepts, declared rather than discovered: an
|
|
102
|
+
# undeclared flag is a usage error the parser names, instead of a
|
|
103
|
+
# positional that `dir_argument` then reports as a second directory.
|
|
104
|
+
#
|
|
105
|
+
# Absence from this table means "accepts none", not "is exempt from it":
|
|
106
|
+
# `parse_flags` reads `FLAGS.fetch(verb, [])`, so a verb that routes
|
|
107
|
+
# through it refuses an undeclared flag whether or not it is listed. The
|
|
108
|
+
# verbs that skipped the parser entirely are how `okf pro audit --json`
|
|
109
|
+
# came to hand `--json` to `BundleRoot.resolve` and report "holds no OKF
|
|
110
|
+
# bundle" as a FINDING — exit 1, which a pipeline reads as a broken
|
|
111
|
+
# bundle rather than as its own typo.
|
|
112
|
+
FLAGS = {
|
|
113
|
+
"state" => %i[json pretty full],
|
|
114
|
+
"board" => %i[json pretty section],
|
|
115
|
+
"snapshot" => %i[json pretty],
|
|
116
|
+
"unverified" => %i[json pretty],
|
|
117
|
+
"friction" => %i[json pretty issue clear]
|
|
118
|
+
}.freeze
|
|
119
|
+
|
|
120
|
+
# How each flag is spelled in the usage. Read FROM `FLAGS` rather than
|
|
121
|
+
# typed beside it, so a flag added to one verb cannot be missing from the
|
|
122
|
+
# help — `parse_flags` tells a user that `okf pro --help` lists what each
|
|
123
|
+
# verb takes, and for a while that was simply untrue.
|
|
124
|
+
FLAG_HELP = {
|
|
125
|
+
json: "--json", pretty: "--pretty", full: "--full",
|
|
126
|
+
section: "--section NAME", issue: "--issue", clear: "--clear"
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
# Where a friction report goes. It is about okf-pro, not about the
|
|
130
|
+
# adopter's knowledge base, so it is this repository regardless of whose
|
|
131
|
+
# tree the bundle lives in. Nothing is ever filed automatically.
|
|
132
|
+
ISSUE_REPO = "serradura/okf-gem"
|
|
133
|
+
|
|
134
|
+
HELP = %w[--help -h help].freeze
|
|
135
|
+
|
|
136
|
+
# The bare semantic version, matching `okf --version` and
|
|
137
|
+
# `okf mcp --version`. No gem name: the caller has already named the
|
|
138
|
+
# extension on the command line, so a name in the output buys nothing and
|
|
139
|
+
# costs a script a `cut`. (`okf tui --version` prints `okf-tui 1.0.0` and
|
|
140
|
+
# is the odd one out; changing a released gem's output is not this gem's
|
|
141
|
+
# to do.)
|
|
142
|
+
VERSION_FLAGS = %w[--version -v version].freeze
|
|
143
|
+
|
|
144
|
+
module_function
|
|
145
|
+
|
|
146
|
+
# The three PostToolUse checks in one process, sharing one bundle read —
|
|
147
|
+
# the read is the expensive part, and three separate invocations paid for
|
|
148
|
+
# it three times over.
|
|
149
|
+
def post_edit(event)
|
|
150
|
+
target = Target.for(event)
|
|
151
|
+
# The second friction point, and the same argument as the first: this
|
|
152
|
+
# runs on every Edit/Write already, so recording here costs no hook
|
|
153
|
+
# registration an adopter would never receive. Only the files a verb
|
|
154
|
+
# covers count — an Edit to a concept body is judgment and always will
|
|
155
|
+
# be, and counting it would report the system working as friction.
|
|
156
|
+
Friction.record(target.root, "edit", target.rel) if target && Friction.covered_path?(target.rel)
|
|
157
|
+
Conformance.check(target) + Budget.cap_check(target) + Reconcile.search(target, event)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
161
|
+
check = argv.shift.to_s
|
|
162
|
+
|
|
163
|
+
# Asking for help is not an error, so it goes to stdout and exits 0.
|
|
164
|
+
# Asking for nothing is: `okf pro` alone did nothing anyone requested,
|
|
165
|
+
# and a 0 there would say it did.
|
|
166
|
+
return usage(stdout, PASS) if HELP.include?(check)
|
|
167
|
+
|
|
168
|
+
if VERSION_FLAGS.include?(check)
|
|
169
|
+
stdout.puts VERSION
|
|
170
|
+
return PASS
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
return usage(stderr, BLOCK) if check.empty?
|
|
174
|
+
|
|
175
|
+
# Past this line the answer can only be PASS-with-a-check-run or BLOCK,
|
|
176
|
+
# which is exactly what the marker claims. It is not emitted for the CI
|
|
177
|
+
# verbs: they are read by a person and a pipeline, not by the wrapper.
|
|
178
|
+
stderr.puts MARKER if HOOK_NAMES.include?(check)
|
|
179
|
+
|
|
180
|
+
if READERS.include?(check) || WRITERS.include?(check)
|
|
181
|
+
# Whitelisted before it is sent: the list above is the composition
|
|
182
|
+
# table, and dispatching off an unvalidated name is how `hook audit`
|
|
183
|
+
# became a gate that always said fine.
|
|
184
|
+
return send(check, argv, stdout: stdout, stderr: stderr)
|
|
185
|
+
end
|
|
186
|
+
return scaffold(check, argv, stdout: stdout, stderr: stderr) if SCAFFOLD.include?(check)
|
|
187
|
+
return session_context(stdin, stdout: stdout) if check == "session-context"
|
|
188
|
+
|
|
189
|
+
handler = CHECKS[check]
|
|
190
|
+
unless handler
|
|
191
|
+
# An unknown check name is enforcement that did not run. The shell
|
|
192
|
+
# dispatcher this replaces fell off the end of its `case` and exited 0
|
|
193
|
+
# on a typo — silence in the one place the contract names.
|
|
194
|
+
stderr.puts "ENFORCEMENT MISCONFIGURED — no check named '#{check}'. Known: #{NAMES.join(", ")}."
|
|
195
|
+
return BLOCK
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
event = Event.from_stdin(stdin)
|
|
199
|
+
if event.parse_error?
|
|
200
|
+
stderr.puts "ENFORCEMENT DEGRADED — '#{check}' could not read its input: #{event.parse_error}. " \
|
|
201
|
+
"No check ran, so nothing here has been checked."
|
|
202
|
+
return BLOCK
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The hook protocol reads every exit but 2 as NON-BLOCKING, so an
|
|
206
|
+
# exception that escapes a check is not an error report — it is the
|
|
207
|
+
# edit sailing through while the gate lies on the floor. Refusing on
|
|
208
|
+
# any crash is the contract's floor: a gate that cannot check must
|
|
209
|
+
# not wave things through.
|
|
210
|
+
begin
|
|
211
|
+
result = handler.call(event)
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
stderr.puts "ENFORCEMENT ERROR — '#{check}' crashed (#{e.class}: #{e.message}); " \
|
|
214
|
+
"nothing was checked, so the call is refused. A crash that passed " \
|
|
215
|
+
"would be indistinguishable from a clean bundle."
|
|
216
|
+
return BLOCK
|
|
217
|
+
end
|
|
218
|
+
return PASS if result.empty?
|
|
219
|
+
|
|
220
|
+
# A check that returns {"ask" => reason} is routing the decision to the
|
|
221
|
+
# owner instead of refusing: the hook protocol reads exit 0 plus this
|
|
222
|
+
# JSON as "prompt the user". Interactive, the owner approves or denies
|
|
223
|
+
# in the moment; unattended, there is no approver and the write fails
|
|
224
|
+
# closed at the permission layer. Arrays stay refusals, as ever.
|
|
225
|
+
if result.is_a?(Hash)
|
|
226
|
+
stdout.puts JSON.generate(
|
|
227
|
+
"hookSpecificOutput" => {
|
|
228
|
+
"hookEventName" => "PreToolUse",
|
|
229
|
+
"permissionDecision" => "ask",
|
|
230
|
+
"permissionDecisionReason" => result.fetch("ask")
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
return PASS
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
stderr.puts result.join("\n")
|
|
237
|
+
BLOCK
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def audit(argv, stdout: $stdout, stderr: $stderr)
|
|
241
|
+
options = parse_flags(argv, "audit", stdout, stderr)
|
|
242
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
243
|
+
|
|
244
|
+
root = dir_argument(argv, "audit", stderr)
|
|
245
|
+
return BLOCK if root == REFUSED
|
|
246
|
+
|
|
247
|
+
begin
|
|
248
|
+
msgs = Audit.call(root || Dir.pwd)
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
stderr.puts "okf pro audit — could not run (#{e.class}: #{e.message}); nothing was " \
|
|
251
|
+
"checked. This is exit 2, not 1: 1 means findings, and a pipeline that " \
|
|
252
|
+
"cannot tell a broken bundle from a broken checker learns to ignore both."
|
|
253
|
+
return BLOCK
|
|
254
|
+
end
|
|
255
|
+
if msgs.empty?
|
|
256
|
+
stdout.puts "okf pro audit — clean."
|
|
257
|
+
return PASS
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
stderr.puts "okf pro audit — #{msgs.size} finding(s):\n#{msgs.join("\n")}"
|
|
261
|
+
FAIL
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The append-only record, asked of the index. Separate from `audit`
|
|
265
|
+
# because it reads a CHANGE (git's staged diff) rather than a STATE,
|
|
266
|
+
# and the pre-commit door materialises a tree where that change is no
|
|
267
|
+
# longer visible as one.
|
|
268
|
+
def records(argv, stdout: $stdout, stderr: $stderr)
|
|
269
|
+
options = parse_flags(argv, "records", stdout, stderr)
|
|
270
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
271
|
+
|
|
272
|
+
root = dir_argument(argv, "records", stderr)
|
|
273
|
+
return BLOCK if root == REFUSED
|
|
274
|
+
|
|
275
|
+
begin
|
|
276
|
+
msgs = Records.staged_violations(root || Dir.pwd)
|
|
277
|
+
rescue StandardError => e
|
|
278
|
+
stderr.puts "okf pro records — could not run (#{e.class}: #{e.message}); the staged " \
|
|
279
|
+
"diff was never read. Exit 2, for the reason `audit` gives."
|
|
280
|
+
return BLOCK
|
|
281
|
+
end
|
|
282
|
+
if msgs.empty?
|
|
283
|
+
stdout.puts "okf pro records — append-only."
|
|
284
|
+
return PASS
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
stderr.puts "okf pro records — #{msgs.size} finding(s):\n#{msgs.join("\n")}"
|
|
288
|
+
FAIL
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The checker-first half of derivation: computes the mechanical line for
|
|
292
|
+
# a person to append. It prints and never writes — Agent Drift killed the
|
|
293
|
+
# generator, and this is not one.
|
|
294
|
+
def snapshot(argv, stdout: $stdout, stderr: $stderr)
|
|
295
|
+
options = parse_flags(argv, "snapshot", stdout, stderr)
|
|
296
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
297
|
+
|
|
298
|
+
root = bundle_for(argv, "snapshot", stderr)
|
|
299
|
+
return BLOCK if root.nil?
|
|
300
|
+
|
|
301
|
+
unless File.exist?(File.join(root, "board.md"))
|
|
302
|
+
stderr.puts "okf pro snapshot — #{root} has no board.md to count."
|
|
303
|
+
return BLOCK
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
counters = guarded("snapshot", stderr) { Snapshot.counters(root) }
|
|
307
|
+
return BLOCK if counters.nil?
|
|
308
|
+
|
|
309
|
+
# The rendered line travels WITH the counters rather than instead of
|
|
310
|
+
# them. It is what a person appends to `log.md`, and a consumer that
|
|
311
|
+
# had to re-render it from the twelve numbers would be a second
|
|
312
|
+
# implementation of the one shape the stop gate verifies.
|
|
313
|
+
line = Snapshot.render(counters)
|
|
314
|
+
emit(stdout, options, { "line" => line, "counters" => counters }) { line }
|
|
315
|
+
PASS
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Report-only, and for a shape of reason worth stating: absent
|
|
319
|
+
# attestation is the truth, and a gate here would pressure toward the
|
|
320
|
+
# one lie the system guards against.
|
|
321
|
+
def unverified(argv, stdout: $stdout, stderr: $stderr)
|
|
322
|
+
options = parse_flags(argv, "unverified", stdout, stderr)
|
|
323
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
324
|
+
|
|
325
|
+
root = bundle_for(argv, "unverified", stderr)
|
|
326
|
+
return BLOCK if root.nil?
|
|
327
|
+
|
|
328
|
+
rows = guarded("unverified", stderr) { Attestation.rows(root) }
|
|
329
|
+
return BLOCK if rows.nil?
|
|
330
|
+
|
|
331
|
+
emit(stdout, options, rows) do
|
|
332
|
+
if rows.empty?
|
|
333
|
+
[ "okf pro unverified — nothing awaits a read." ]
|
|
334
|
+
else
|
|
335
|
+
[ "okf pro unverified — #{rows.size} concept(s) awaiting the owner's read " \
|
|
336
|
+
"(the state is the truth, not a defect):", *Attestation.render(rows) ]
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
PASS
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ── the readers ──────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def state(argv, stdout: $stdout, stderr: $stderr)
|
|
345
|
+
options = parse_flags(argv, "state", stdout, stderr)
|
|
346
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
347
|
+
|
|
348
|
+
root = bundle_with_board(argv, "state", stderr)
|
|
349
|
+
return BLOCK if root.nil?
|
|
350
|
+
|
|
351
|
+
payload = guarded("state", stderr) { State.call(root, full: options[:full]) }
|
|
352
|
+
return BLOCK if payload.nil?
|
|
353
|
+
|
|
354
|
+
emit(stdout, options, payload) { State.render(payload) }
|
|
355
|
+
PASS
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def board(argv, stdout: $stdout, stderr: $stderr)
|
|
359
|
+
options = parse_flags(argv, "board", stdout, stderr)
|
|
360
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
361
|
+
|
|
362
|
+
root = bundle_with_board(argv, "board", stderr)
|
|
363
|
+
return BLOCK if root.nil?
|
|
364
|
+
|
|
365
|
+
text = Pro.read_text(File.join(root, "board.md"))
|
|
366
|
+
rows = Board.rows(text)
|
|
367
|
+
if options[:section]
|
|
368
|
+
# An empty section and a misspelled one look identical in the output
|
|
369
|
+
# and mean opposite things, so the heading is asked for by name. This
|
|
370
|
+
# is the quiet-zero class the board's own grammar check exists for.
|
|
371
|
+
unless section?(text, options[:section])
|
|
372
|
+
stderr.puts "okf pro board — the board has no '## #{options[:section]}' section. " \
|
|
373
|
+
"An empty answer here would be indistinguishable from a section that is simply empty."
|
|
374
|
+
return BLOCK
|
|
375
|
+
end
|
|
376
|
+
rows = rows.select { |row| row.section.casecmp(options[:section]).zero? }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
today = Date.today
|
|
380
|
+
payload = rows.map { |row| board_row(row, today) }
|
|
381
|
+
emit(stdout, options, payload) { render_board(payload) }
|
|
382
|
+
PASS
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def section?(text, name)
|
|
386
|
+
heading = "## #{name}"
|
|
387
|
+
Board.visible(text).each_line.any? { |line| line.start_with?("## ") && line.chomp.rstrip.casecmp(heading).zero? }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def board_row(row, today)
|
|
391
|
+
date = row.line_date
|
|
392
|
+
{
|
|
393
|
+
"section" => row.section,
|
|
394
|
+
"text" => row.text,
|
|
395
|
+
"date" => date&.to_s,
|
|
396
|
+
"age" => date && (today - date).to_i,
|
|
397
|
+
"chase" => row.chase_date&.to_s,
|
|
398
|
+
"targets" => row.targets
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def render_board(rows)
|
|
403
|
+
return [ "okf pro board — no lines." ] if rows.empty?
|
|
404
|
+
|
|
405
|
+
rows.map do |row|
|
|
406
|
+
age = row["age"] ? " (#{row["age"]}d)" : ""
|
|
407
|
+
chase = row["chase"] ? " [chase #{row["chase"]}]" : ""
|
|
408
|
+
"[#{row["section"]}]#{age}#{chase} #{row["text"]}"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def friction(argv, stdout: $stdout, stderr: $stderr)
|
|
413
|
+
options = parse_flags(argv, "friction", stdout, stderr)
|
|
414
|
+
return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
|
|
415
|
+
|
|
416
|
+
root = bundle_for(argv, "friction", stderr)
|
|
417
|
+
return BLOCK if root.nil?
|
|
418
|
+
|
|
419
|
+
return clear_friction(stdout, root) if options[:clear]
|
|
420
|
+
|
|
421
|
+
report = Friction.report(root)
|
|
422
|
+
rows = friction_rows(report)
|
|
423
|
+
return issue_report(stdout, root, report, rows) if options[:issue]
|
|
424
|
+
|
|
425
|
+
payload = { "available" => report.available, "recorded" => report.events.size,
|
|
426
|
+
"unreadable" => report.unreadable, "by" => rows }
|
|
427
|
+
emit(stdout, options, payload) { render_friction(report, rows) }
|
|
428
|
+
PASS
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Grouped by the DOOR as well as the file. An Edit to the board and a
|
|
432
|
+
# shell redirect at it are different findings about the same path: one
|
|
433
|
+
# says a verb went unused, the other says the trust guards were bypassed
|
|
434
|
+
# entirely, and what covers them is not the same answer.
|
|
435
|
+
def friction_rows(report)
|
|
436
|
+
counts = report.events.each_with_object({}) do |event, acc|
|
|
437
|
+
key = [ event["via"].to_s, event["what"].to_s ]
|
|
438
|
+
acc[key] = (acc[key] || 0) + 1
|
|
439
|
+
end
|
|
440
|
+
counts.sort_by { |(via, what), n| [ -n, via, what ] }.map do |(via, what), n|
|
|
441
|
+
{ "via" => via, "what" => what, "count" => n, "covered by" => Friction.covered_by(via, what) }
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# The way out of a sticky marker, and the way to stop a lifetime total
|
|
446
|
+
# nagging about a week nobody can change. It touches `.tmp/` and nothing
|
|
447
|
+
# else — this is telemetry, not knowledge, and no bundle file is involved.
|
|
448
|
+
def clear_friction(stdout, root)
|
|
449
|
+
removed = Friction.clear(root)
|
|
450
|
+
if removed.nil?
|
|
451
|
+
stdout.puts "okf pro friction — could not clear #{File.dirname(Friction.log_path(root))}. " \
|
|
452
|
+
"Remove the files by hand: #{Friction.log_path(root)} and #{Friction.marker_path(root)}."
|
|
453
|
+
return PASS
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
stdout.puts "okf pro friction — cleared (#{removed} file(s) removed). The count starts again from here."
|
|
457
|
+
PASS
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Report-only, exit 0 — the family `unverified` belongs to. It measures
|
|
461
|
+
# this gem, not the adopter's bundle, and a measurement that gated
|
|
462
|
+
# something would start being gamed the day someone noticed.
|
|
463
|
+
# Nothing readable and nothing recorded are different states, and this is
|
|
464
|
+
# the surface a person meets first. `--issue` already treats them apart —
|
|
465
|
+
# it declines to file for one and files for the other — so a default that
|
|
466
|
+
# called an unparseable log "nothing recorded" made the human output the
|
|
467
|
+
# one that lied.
|
|
468
|
+
def unreadable_note(report)
|
|
469
|
+
"okf pro friction — #{report.unreadable} recorded line(s) will not parse and nothing else " \
|
|
470
|
+
"is recorded, so this is a corrupted log rather than a quiet one. Nothing here is a " \
|
|
471
|
+
"zero. `okf pro friction --clear` starts the count again."
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def render_friction(report, rows)
|
|
475
|
+
return [ UNAVAILABLE_NOTE ] unless report.available
|
|
476
|
+
|
|
477
|
+
if rows.empty?
|
|
478
|
+
return [ unreadable_note(report) ] if report.unreadable.positive?
|
|
479
|
+
|
|
480
|
+
return [ "okf pro friction — nothing recorded. Either the verbs covered it, or nothing was written by hand." ]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
lines = [ "okf pro friction — #{report.events.size} bundle edit(s) recorded so far that a " \
|
|
484
|
+
"command could have done:" ]
|
|
485
|
+
rows.each do |row|
|
|
486
|
+
label = "#{row["via"]} #{row["what"]}"
|
|
487
|
+
lines << " #{label.ljust(16)} #{row["count"]}#{" now: #{row["covered by"]}" if row["covered by"]}"
|
|
488
|
+
end
|
|
489
|
+
lines << " #{report.unreadable} recorded line(s) could not be parsed and are not counted above." if report.unreadable.positive?
|
|
490
|
+
lines << "If one of these should be an `okf pro` verb, please tell the maintainer — " \
|
|
491
|
+
"`okf pro friction --issue` prints a ready-to-paste report. It helps more than you think."
|
|
492
|
+
lines << "The count is cumulative; `okf pro friction --clear` starts it again."
|
|
493
|
+
lines
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Names the file, because "check that .tmp/ is writable" is not actionable
|
|
497
|
+
# once the marker is the thing keeping the answer unknown — the directory
|
|
498
|
+
# may be perfectly writable now and the report still says unknown, which
|
|
499
|
+
# is correct and infuriating without the next sentence.
|
|
500
|
+
UNAVAILABLE_NOTE = "okf pro friction — the recorder could not write at some point, so this is " \
|
|
501
|
+
"not zero, it is unknown. Check that .tmp/ is writable at the repository " \
|
|
502
|
+
"root, then `okf pro friction --clear` to start counting again."
|
|
503
|
+
|
|
504
|
+
# Printed, never run. Filing an issue is outward-facing and irreversible,
|
|
505
|
+
# and a hook that did it unattended would be both without anyone asking.
|
|
506
|
+
def issue_report(stdout, root, report, rows)
|
|
507
|
+
# Nothing counted, and the recorder says it counted honestly — so there
|
|
508
|
+
# is nothing to file. The report drafted here was complete and
|
|
509
|
+
# ready-to-paste and said "Recorded over 0 event(s)", which asks the
|
|
510
|
+
# maintainer to act on an empty list.
|
|
511
|
+
#
|
|
512
|
+
# Two states are NOT that, and both still file. "The recorder could not
|
|
513
|
+
# write" is something that happened; so is a log whose every line was
|
|
514
|
+
# unparseable, which is a corrupted recorder rather than a quiet one —
|
|
515
|
+
# zero rows with a positive `unreadable` is the shape that would
|
|
516
|
+
# otherwise be reported as nothing at all.
|
|
517
|
+
if rows.empty? && report.available && report.unreadable.zero?
|
|
518
|
+
stdout.puts "okf pro friction — nothing recorded, so there is no report to file. " \
|
|
519
|
+
"Either the verbs covered it or nothing was written by hand; an issue " \
|
|
520
|
+
"whose body is an empty list asks the maintainer to act on nothing."
|
|
521
|
+
return PASS
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Says what was counted, and claims nothing about what covers it. The
|
|
525
|
+
# banner had just established that a shell redirect's answer is Edit or
|
|
526
|
+
# Write rather than a verb, and a title reading "that a verb could
|
|
527
|
+
# cover" over a body whose only row says otherwise asks the maintainer
|
|
528
|
+
# for a verb this gem decided not to want. The `covered by` note on
|
|
529
|
+
# each row is where that question is actually answered.
|
|
530
|
+
title = "okf-pro: #{report.events.size} bundle edit(s) recorded by hand"
|
|
531
|
+
body = issue_body(root, report, rows)
|
|
532
|
+
if which("gh")
|
|
533
|
+
stdout.puts "Run this — it is not run for you, because filing an issue is yours to decide:"
|
|
534
|
+
stdout.puts
|
|
535
|
+
stdout.puts "gh issue create --repo #{ISSUE_REPO} \\"
|
|
536
|
+
stdout.puts " --title #{shell_quote(title)} \\"
|
|
537
|
+
stdout.puts " --body #{shell_quote(body)}"
|
|
538
|
+
else
|
|
539
|
+
stdout.puts "`gh` is not on PATH, so here is the issue to paste:"
|
|
540
|
+
stdout.puts
|
|
541
|
+
stdout.puts " https://github.com/#{ISSUE_REPO}/issues/new"
|
|
542
|
+
stdout.puts
|
|
543
|
+
stdout.puts "Title: #{title}"
|
|
544
|
+
stdout.puts
|
|
545
|
+
stdout.puts body
|
|
546
|
+
end
|
|
547
|
+
PASS
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def issue_body(_root, report, rows)
|
|
551
|
+
lines = [ "`okf pro friction` recorded these while working in a bundle. Each is an edit made",
|
|
552
|
+
"by hand inside it — some to a file a command already covers, some through a door",
|
|
553
|
+
"the trust guards cannot see at all. The `covered by` note says which.", "" ]
|
|
554
|
+
# The evidence is printed whatever the recorder's state. Branching the
|
|
555
|
+
# whole body on `available` dropped every row it had and then said "the
|
|
556
|
+
# counts above are unknown" over a body with nothing above it — a
|
|
557
|
+
# sentence about text that was not there, under a title that still
|
|
558
|
+
# counted the rows. The marker is sticky by design, so one failed write
|
|
559
|
+
# weeks ago made every later report evidence-free.
|
|
560
|
+
rows.each do |row|
|
|
561
|
+
covered = row["covered by"]
|
|
562
|
+
lines << "* `#{row["via"]} #{row["what"]}` — #{row["count"]} time(s)#{" (covered by: #{covered})" if covered}"
|
|
563
|
+
end
|
|
564
|
+
lines << "" << "Recorded over #{report.events.size} event(s)."
|
|
565
|
+
lines << "#{report.unreadable} recorded line(s) were unparseable and are excluded." if report.unreadable.positive?
|
|
566
|
+
unless report.available
|
|
567
|
+
lines << "The recorder could not write at some point, so this is a floor rather than a " \
|
|
568
|
+
"total: the real count is higher by an unknown amount."
|
|
569
|
+
end
|
|
570
|
+
lines << "" << "okf-pro #{VERSION}, ruby #{RUBY_VERSION}."
|
|
571
|
+
lines.join("\n")
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Single-quoted with the one escape a POSIX shell accepts inside them.
|
|
575
|
+
# The body is assembled from recorded `what` values, which are this gem's
|
|
576
|
+
# own vocabulary — but printing a command a reader will paste is exactly
|
|
577
|
+
# the place not to assume that.
|
|
578
|
+
def shell_quote(text)
|
|
579
|
+
# Block form, and it is load-bearing: in a replacement STRING, `\'` is
|
|
580
|
+
# gsub's post-match special, so the string form of this substitution
|
|
581
|
+
# spliced the rest of the text back in after every apostrophe. A block
|
|
582
|
+
# return is taken literally.
|
|
583
|
+
"'#{text.to_s.gsub("'") { "'\\''" }}'"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def which(name)
|
|
587
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
588
|
+
path = File.join(dir, name)
|
|
589
|
+
File.file?(path) && File.executable?(path)
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# ── the writers ──────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
def capture(argv, stdout: $stdout, stderr: $stderr)
|
|
596
|
+
answered = writer_flags(argv, "capture", stdout, stderr)
|
|
597
|
+
return answered == :handled ? PASS : BLOCK unless answered == :ok
|
|
598
|
+
|
|
599
|
+
text = argv.shift
|
|
600
|
+
if text.nil?
|
|
601
|
+
stderr.puts "okf pro capture — takes the words to capture: `okf pro capture \"what you heard\"`."
|
|
602
|
+
return BLOCK
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
write_verb("capture", argv, stdout, stderr) { |root| Writes.capture(root, text) }
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def promote(argv, stdout: $stdout, stderr: $stderr)
|
|
609
|
+
selector_verb("promote", argv, stdout, stderr) { |root, selector| Writes.promote(root, selector) }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def demote(argv, stdout: $stdout, stderr: $stderr)
|
|
613
|
+
selector_verb("demote", argv, stdout, stderr) { |root, selector| Writes.demote(root, selector) }
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def journal(argv, stdout: $stdout, stderr: $stderr)
|
|
617
|
+
answered = writer_flags(argv, "journal", stdout, stderr)
|
|
618
|
+
return answered == :handled ? PASS : BLOCK unless answered == :ok
|
|
619
|
+
|
|
620
|
+
sub = argv.shift
|
|
621
|
+
unless sub == "open"
|
|
622
|
+
stderr.puts "okf pro journal — takes one subcommand, `open`, and was given " \
|
|
623
|
+
"#{sub.nil? ? "none" : "'#{sub}'"}. `okf pro journal open` creates today's day " \
|
|
624
|
+
"file and its index line; what goes in it is yours to write."
|
|
625
|
+
return BLOCK
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
write_verb("journal open", argv, stdout, stderr) { |root| Writes.journal_open(root) }
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# `close` takes a PROJECT, not a board-line selector, and the difference
|
|
632
|
+
# is not pedantry: `Writes.close` requires one directory segment, so the
|
|
633
|
+
# `/projects/<slug>/index.md` form a board line actually carries — the
|
|
634
|
+
# one `okf pro board` prints — is a refusal, and so is a substring. The
|
|
635
|
+
# shared message offered both, which sends the reader to try what the
|
|
636
|
+
# verb rejects.
|
|
637
|
+
CLOSE_ARGUMENT = "takes the project to close: its directory name under projects/, or the " \
|
|
638
|
+
"`/projects/<slug>/` link a board line carries (with or without its " \
|
|
639
|
+
"`index.md`). It names one directory segment either way, so a substring " \
|
|
640
|
+
"of a board line is not one. `okf pro state` lists the open ones."
|
|
641
|
+
|
|
642
|
+
def close(argv, stdout: $stdout, stderr: $stderr)
|
|
643
|
+
selector_verb("close", argv, stdout, stderr, missing: CLOSE_ARGUMENT) { |root, slug| Writes.close(root, slug) }
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
SELECTOR_ARGUMENT = "takes what to act on: a `/projects/<slug>` link, a slug, or a " \
|
|
647
|
+
"substring only one board line carries. `okf pro board` lists them."
|
|
648
|
+
|
|
649
|
+
def selector_verb(verb, argv, stdout, stderr, missing: SELECTOR_ARGUMENT)
|
|
650
|
+
answered = writer_flags(argv, verb, stdout, stderr)
|
|
651
|
+
return answered == :handled ? PASS : BLOCK unless answered == :ok
|
|
652
|
+
|
|
653
|
+
selector = argv.shift
|
|
654
|
+
if selector.nil?
|
|
655
|
+
stderr.puts "okf pro #{verb} — #{missing}"
|
|
656
|
+
return BLOCK
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
write_verb(verb, argv, stdout, stderr) { |root| yield(root, selector) }
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# The writers take no flags, and that is precisely why they need this.
|
|
663
|
+
#
|
|
664
|
+
# Their first positional is CONTENT — the words to capture, the line to
|
|
665
|
+
# move — so a mistyped or misremembered flag is not an error, it is data:
|
|
666
|
+
# `okf pro capture --help` appended `- <date> — --help` to the Inbox and
|
|
667
|
+
# exited 0. A verb whose failure mode is committing a garbage board line
|
|
668
|
+
# must refuse a leading dash rather than swallow it.
|
|
669
|
+
#
|
|
670
|
+
# `--help` and `--version` are answered because every other verb answers
|
|
671
|
+
# them, and `--` is the POSIX escape for the rare legitimate case of
|
|
672
|
+
# content that really does begin with a dash.
|
|
673
|
+
#
|
|
674
|
+
# Returns `:ok` to carry on, `:handled` when the caller has been answered,
|
|
675
|
+
# or nil on a refusal already reported.
|
|
676
|
+
def writer_flags(argv, verb, stdout, stderr)
|
|
677
|
+
first = argv.first.to_s
|
|
678
|
+
return help_answer(stdout) if HELP.include?(first)
|
|
679
|
+
return version_answer(stdout) if VERSION_FLAGS.include?(first)
|
|
680
|
+
|
|
681
|
+
if first == "--"
|
|
682
|
+
argv.shift
|
|
683
|
+
return :ok
|
|
684
|
+
end
|
|
685
|
+
return :ok unless first.start_with?("-")
|
|
686
|
+
|
|
687
|
+
stderr.puts "okf pro #{verb} — '#{first}' looks like a flag, and this verb takes none: its " \
|
|
688
|
+
"first argument is content, so a flag swallowed here becomes a board line " \
|
|
689
|
+
"nobody meant to write. If you really meant to #{verb} something starting with " \
|
|
690
|
+
"a dash, put `--` in front of it."
|
|
691
|
+
nil
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# The shape every writer shares: resolve, run, print, choose the code.
|
|
695
|
+
# The write itself never happens here — `Writes` runs the conservation
|
|
696
|
+
# guard and refuses before touching the disk, so this method cannot make
|
|
697
|
+
# a partial write even by getting the ordering wrong.
|
|
698
|
+
def write_verb(verb, argv, stdout, stderr)
|
|
699
|
+
root = bundle_for(argv, verb, stderr)
|
|
700
|
+
return BLOCK if root.nil?
|
|
701
|
+
|
|
702
|
+
result = guarded(verb, stderr) { yield(root) }
|
|
703
|
+
return BLOCK if result.nil?
|
|
704
|
+
|
|
705
|
+
if result.ok
|
|
706
|
+
stdout.puts result.messages unless result.messages.empty?
|
|
707
|
+
return PASS
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
stderr.puts result.messages
|
|
711
|
+
BLOCK
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# ── the shared plumbing ──────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
# Parsed BEFORE `dir_argument`, which refuses any second positional and
|
|
717
|
+
# would otherwise report `okf pro state . --json` as two directories.
|
|
718
|
+
#
|
|
719
|
+
# `--help` and `--version` are declared rather than left to OptionParser's
|
|
720
|
+
# "officious" defaults, which call `exit` from inside the parse — a
|
|
721
|
+
# process exit out of a library call, in a gem whose whole subject is
|
|
722
|
+
# which exit code a gate returns.
|
|
723
|
+
#
|
|
724
|
+
# Returns the options Hash, `:help` when the caller asked for the usage,
|
|
725
|
+
# or nil on a parse error already reported.
|
|
726
|
+
def parse_flags(argv, verb, stdout, stderr)
|
|
727
|
+
options = { json: false, pretty: false, full: false, issue: false, clear: false, section: nil }
|
|
728
|
+
allowed = FLAGS.fetch(verb, [])
|
|
729
|
+
parser = OptionParser.new do |o|
|
|
730
|
+
o.banner = "Usage: okf pro #{verb} [DIR]"
|
|
731
|
+
o.on("--json") { options[:json] = true } if allowed.include?(:json)
|
|
732
|
+
o.on("--pretty") { options[:json] = options[:pretty] = true } if allowed.include?(:pretty)
|
|
733
|
+
o.on("--full") { options[:full] = true } if allowed.include?(:full)
|
|
734
|
+
o.on("--issue") { options[:issue] = true } if allowed.include?(:issue)
|
|
735
|
+
o.on("--clear") { options[:clear] = true } if allowed.include?(:clear)
|
|
736
|
+
o.on("--section NAME") { |value| options[:section] = value } if allowed.include?(:section)
|
|
737
|
+
o.on("-h", "--help") { options[:help] = true }
|
|
738
|
+
o.on("-v", "--version") { options[:version] = true }
|
|
739
|
+
end
|
|
740
|
+
parser.parse!(argv)
|
|
741
|
+
return version_answer(stdout) if options[:version]
|
|
742
|
+
return help_answer(stdout) if options[:help]
|
|
743
|
+
|
|
744
|
+
options
|
|
745
|
+
rescue OptionParser::ParseError => e
|
|
746
|
+
stderr.puts "okf pro #{verb} — #{e.message}. `okf pro --help` lists what each verb takes."
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Not the options Hash and not nil: the caller has already been answered,
|
|
751
|
+
# and there is nothing left to do but exit 0.
|
|
752
|
+
def help_answer(stdout)
|
|
753
|
+
usage(stdout, PASS)
|
|
754
|
+
:handled
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def version_answer(stdout)
|
|
758
|
+
stdout.puts VERSION
|
|
759
|
+
:handled
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def emit(stdout, options, payload)
|
|
763
|
+
if options[:json]
|
|
764
|
+
stdout.puts(options[:pretty] ? JSON.pretty_generate(payload) : JSON.generate(payload))
|
|
765
|
+
else
|
|
766
|
+
stdout.puts yield
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# A verb that cannot run exits 2, never 1 — 1 means findings, and a
|
|
771
|
+
# pipeline that cannot tell a broken bundle from a broken checker learns
|
|
772
|
+
# to ignore both. The same reasoning `audit` states at length.
|
|
773
|
+
def guarded(verb, stderr)
|
|
774
|
+
yield
|
|
775
|
+
rescue StandardError => e
|
|
776
|
+
stderr.puts "okf pro #{verb} — could not run (#{e.class}: #{e.message}); nothing was " \
|
|
777
|
+
"read or written. Exit 2, for the reason `audit` gives."
|
|
778
|
+
nil
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def bundle_for(argv, verb, stderr)
|
|
782
|
+
start = dir_argument(argv, verb, stderr)
|
|
783
|
+
return nil if start == REFUSED
|
|
784
|
+
|
|
785
|
+
resolve_or_complain(start, verb, stderr)
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def bundle_with_board(argv, verb, stderr)
|
|
789
|
+
root = bundle_for(argv, verb, stderr)
|
|
790
|
+
return nil if root.nil?
|
|
791
|
+
return root if File.exist?(File.join(root, "board.md"))
|
|
792
|
+
|
|
793
|
+
stderr.puts "okf pro #{verb} — #{root} has no board.md to read."
|
|
794
|
+
nil
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def scaffold(verb, argv, stdout: $stdout, stderr: $stderr)
|
|
798
|
+
dest = argv.shift
|
|
799
|
+
|
|
800
|
+
unless argv.empty?
|
|
801
|
+
stderr.puts "okf pro #{verb} — takes one directory, and #{argv.size + 1} were given."
|
|
802
|
+
return BLOCK
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
if verb == "skill"
|
|
806
|
+
if dest.nil?
|
|
807
|
+
stderr.puts "okf pro skill — needs a destination directory: `okf pro skill .claude/skills/okf-pro`."
|
|
808
|
+
return BLOCK
|
|
809
|
+
end
|
|
810
|
+
else
|
|
811
|
+
dest ||= Dir.pwd
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
if dest.to_s.start_with?("@")
|
|
815
|
+
stderr.puts "okf pro #{verb} — '#{dest}' is a registry ref, and this verb takes a path. " \
|
|
816
|
+
"The registry names where a bundle is; this writes a repository around one."
|
|
817
|
+
return BLOCK
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
Scaffold.public_send(verb, dest, out: stdout, err: stderr)
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# The command list, plus the two things that surprise people — a ref is not
|
|
824
|
+
# a path here, and `hook` does not speak this repo's exit codes. Both cost
|
|
825
|
+
# something when they surprise someone, which is why they are in the usage
|
|
826
|
+
# rather than only in the guide.
|
|
827
|
+
def usage(stream, status)
|
|
828
|
+
width = USAGE.map { |verb, arg, _| "#{verb} #{arg}".length }.max
|
|
829
|
+
stream.puts "Usage: okf pro <command> [DIR]"
|
|
830
|
+
stream.puts
|
|
831
|
+
USAGE.each do |verb, arg, description|
|
|
832
|
+
stream.puts format(" %-#{width}s %s", "#{verb} #{arg}", description)
|
|
833
|
+
end
|
|
834
|
+
stream.puts
|
|
835
|
+
stream.puts " hook checks: #{HOOK_NAMES.first(5).join(", ")},"
|
|
836
|
+
stream.puts " #{HOOK_NAMES.drop(5).join(", ")}"
|
|
837
|
+
stream.puts
|
|
838
|
+
stream.puts "Flags:"
|
|
839
|
+
FLAGS.each do |verb, flags|
|
|
840
|
+
stream.puts format(" %-#{width}s %s", verb, flags.map { |flag| FLAG_HELP.fetch(flag) }.join(" "))
|
|
841
|
+
end
|
|
842
|
+
stream.puts " --pretty implies --json. `audit` and `records` take none — what they answer"
|
|
843
|
+
stream.puts " with is the exit code. The writers take no flags at all either: their first"
|
|
844
|
+
stream.puts " argument is content, so `--` is what escapes content that starts with a dash."
|
|
845
|
+
stream.puts
|
|
846
|
+
stream.puts "DIR takes a path, not an @slug: the registry names where a bundle is, and a"
|
|
847
|
+
stream.puts "brain is the repository around one — the hooks, the git hooks, the workflow."
|
|
848
|
+
stream.puts
|
|
849
|
+
stream.puts "Exit codes: `hook` speaks the agent hook protocol — 0 pass, 2 block, and never"
|
|
850
|
+
stream.puts "1, which that protocol reads as non-blocking. Every other command: 0 clean,"
|
|
851
|
+
stream.puts "1 findings, 2 could not run."
|
|
852
|
+
stream.puts
|
|
853
|
+
stream.puts "okf pro --version"
|
|
854
|
+
status
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Every bundle-taking verb reads one directory and nothing else.
|
|
858
|
+
#
|
|
859
|
+
# A leading `@` is refused by name rather than treated as a path. `okf
|
|
860
|
+
# help` promises "Anywhere a `<dir>` goes, an `@slug` goes", and that
|
|
861
|
+
# promise is the kernel's; this gem does not keep it yet, and okf-tui
|
|
862
|
+
# already shipped the failure of pretending otherwise — a ref reached the
|
|
863
|
+
# filesystem and came back "not a directory", which tells the user nothing
|
|
864
|
+
# about what is actually missing.
|
|
865
|
+
#
|
|
866
|
+
# A second positional is refused for the reason ../AGENTS.md records
|
|
867
|
+
# against `okf lint a b`: silently reading the first and ignoring the rest
|
|
868
|
+
# is a wrong answer with a green exit code.
|
|
869
|
+
def dir_argument(argv, verb, stderr)
|
|
870
|
+
start = argv.shift
|
|
871
|
+
|
|
872
|
+
unless argv.empty?
|
|
873
|
+
stderr.puts "okf pro #{verb} — takes one directory, and #{argv.size + 1} were given " \
|
|
874
|
+
"(#{([ start ] + argv).join(" ")}). Reading the first and ignoring the rest " \
|
|
875
|
+
"would be a wrong answer with a clean exit code."
|
|
876
|
+
return REFUSED
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
if start.to_s.start_with?("@")
|
|
880
|
+
stderr.puts "okf pro #{verb} — '#{start}' is a registry ref, and this verb takes a " \
|
|
881
|
+
"path. The registry answers where a bundle is; a brain is the repository " \
|
|
882
|
+
"around one, which a ref does not name. Give the directory."
|
|
883
|
+
return REFUSED
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
start
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def resolve_or_complain(start, verb, stderr)
|
|
890
|
+
root = BundleRoot.resolve(start || Dir.pwd)
|
|
891
|
+
if root.nil?
|
|
892
|
+
stderr.puts "okf pro #{verb} — #{File.expand_path((start || Dir.pwd).to_s)} holds no OKF bundle."
|
|
893
|
+
end
|
|
894
|
+
root
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
# The one check that does not refuse. Its output is context, not a verdict,
|
|
898
|
+
# and SessionStart has no blocking channel — so it says what it could not
|
|
899
|
+
# do and lets the session start.
|
|
900
|
+
def session_context(stdin, stdout: $stdout)
|
|
901
|
+
event = Event.from_stdin(stdin)
|
|
902
|
+
if event.parse_error?
|
|
903
|
+
stdout.puts "Bundle state unavailable — #{event.parse_error}. " \
|
|
904
|
+
"The gates still run; only this banner is blind."
|
|
905
|
+
return PASS
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
lines = Closing.session_context(event)
|
|
909
|
+
stdout.puts lines if lines
|
|
910
|
+
PASS
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
end
|