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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module OKF
|
|
6
|
+
module Pro
|
|
7
|
+
# `okf pro setup` and `okf pro upgrade` — the generator, and the one
|
|
8
|
+
# thing it may safely do twice.
|
|
9
|
+
#
|
|
10
|
+
# TWO CLASSES OF FILE, drawn by OWNERSHIP rather than by subject matter.
|
|
11
|
+
#
|
|
12
|
+
# GEM-OWNED the hook wrapper, the pre-commit hook, the workflow, the
|
|
13
|
+
# skill. These carry the fail-closed contract and the exit-code
|
|
14
|
+
# protocol; they must track the gem or a gate goes quietly
|
|
15
|
+
# wrong. `upgrade` rewrites them outright.
|
|
16
|
+
# SEEDED CLAUDE.md, .gitignore, .claude/settings.json, and the whole
|
|
17
|
+
# of .okf/. Written once, and the adopter's from that moment.
|
|
18
|
+
#
|
|
19
|
+
# The split is not knowledge-versus-machinery, which is the obvious cut and
|
|
20
|
+
# the wrong one: `CLAUDE.md` at a repo root is the adopter's own
|
|
21
|
+
# agent-instruction file, `.gitignore` accumulates project entries from day
|
|
22
|
+
# one, and `settings.json` is where they add hooks of their own. Classing
|
|
23
|
+
# those as machinery makes `upgrade` destroy exactly the hand-merge that
|
|
24
|
+
# adopting this thing asked them to perform.
|
|
25
|
+
#
|
|
26
|
+
# WHAT HAPPENS ON A COLLISION. A file the template wants and the adopter
|
|
27
|
+
# already has is written beside it as `<path>.okf-pro-new`, with a merge
|
|
28
|
+
# instruction printed. A stale one is refreshed rather than doubled — there
|
|
29
|
+
# is never a `.okf-pro-new.okf-pro-new`.
|
|
30
|
+
#
|
|
31
|
+
# Except inside `.okf/`. A bundle that already exists is a bundle already
|
|
32
|
+
# adopted, and eleven shadow files scattered through someone's knowledge is
|
|
33
|
+
# not a merge prompt, it is litter — in a directory whose whole value is
|
|
34
|
+
# that everything in it is deliberate. There, only missing files are added.
|
|
35
|
+
#
|
|
36
|
+
# ATOMICITY, STATED HONESTLY: each file is written to a temp path in its own
|
|
37
|
+
# destination directory and renamed, so no half-written file ever exists.
|
|
38
|
+
# A twenty-file sequence is not atomic as a *set*, and nothing here pretends
|
|
39
|
+
# otherwise. The real safety is that `setup` is idempotent and re-runnable.
|
|
40
|
+
module Scaffold
|
|
41
|
+
ROOT = File.expand_path("template", __dir__)
|
|
42
|
+
|
|
43
|
+
# `upgrade` rewrites these.
|
|
44
|
+
GEM_DIR = File.join(ROOT, "gem")
|
|
45
|
+
|
|
46
|
+
# `setup` writes these once; nothing ever rewrites them.
|
|
47
|
+
SEED_DIR = File.join(ROOT, "seed")
|
|
48
|
+
|
|
49
|
+
SUFFIX = ".okf-pro-new"
|
|
50
|
+
|
|
51
|
+
# Stored without its dot and renamed on write. As a real dotfile in this
|
|
52
|
+
# gem's tree it would be a live gitignore governing its own directory —
|
|
53
|
+
# silently deciding what `git ls-files` reports, and therefore what ships.
|
|
54
|
+
DOTFILES = { "gitignore" => ".gitignore" }.freeze
|
|
55
|
+
|
|
56
|
+
# A wrapper without +x does not refuse: git silently skips a
|
|
57
|
+
# non-executable hook, and the shell reports 127, which the hook protocol
|
|
58
|
+
# reads as non-blocking. Both scripts are asserted executable on the
|
|
59
|
+
# generated output, not merely here.
|
|
60
|
+
EXECUTABLE = [ ".claude/hooks/run", ".githooks/pre-commit" ].freeze
|
|
61
|
+
|
|
62
|
+
# The skill alone, for `okf pro skill <dest>`.
|
|
63
|
+
SKILL_REL = ".claude/skills/okf-pro/SKILL.md"
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
def setup(dest, out:, err:)
|
|
68
|
+
dest = File.expand_path(dest.to_s)
|
|
69
|
+
|
|
70
|
+
refusal = flat_layout_refusal(dest)
|
|
71
|
+
if refusal
|
|
72
|
+
err.puts refusal
|
|
73
|
+
return BLOCK
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
report(out, write_tree(GEM_DIR, dest) + write_tree(SEED_DIR, dest), dest, "setup")
|
|
77
|
+
PASS
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def upgrade(dest, out:, err:)
|
|
81
|
+
dest = File.expand_path(dest.to_s)
|
|
82
|
+
|
|
83
|
+
unless File.directory?(dest)
|
|
84
|
+
err.puts "okf pro upgrade — #{dest} is not a directory."
|
|
85
|
+
return BLOCK
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The gem-owned four are rewritten; the seeded ones are only completed.
|
|
89
|
+
# An `upgrade` that overwrote a seeded file would take the adopter's own
|
|
90
|
+
# CLAUDE.md with it, and an `upgrade` that staged every one of them would
|
|
91
|
+
# bury the four files that actually changed under thirteen that did not.
|
|
92
|
+
report(out, write_tree(GEM_DIR, dest, overwrite: true) + write_tree(SEED_DIR, dest), dest, "upgrade")
|
|
93
|
+
PASS
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The destination tree is created — `.claude/skills/okf-pro` is the
|
|
97
|
+
# natural place and `.claude/skills` often does not exist yet, so refusing
|
|
98
|
+
# on a missing parent would refuse the common case to guard against a typo
|
|
99
|
+
# that costs one `rm -r`.
|
|
100
|
+
#
|
|
101
|
+
# Overwritten rather than staged, unlike the seeded files: the skill is
|
|
102
|
+
# gem-owned, and refreshing it is the entire reason this verb exists.
|
|
103
|
+
def skill(dest, out:, err:)
|
|
104
|
+
dest = File.expand_path(dest.to_s)
|
|
105
|
+
|
|
106
|
+
if File.exist?(dest) && !File.directory?(dest)
|
|
107
|
+
err.puts "okf pro skill — #{dest} is a file; the skill is a directory holding SKILL.md."
|
|
108
|
+
return BLOCK
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Every file, not just the entry point. The skill was one file once, and
|
|
112
|
+
# an installer that kept copying only `SKILL.md` would install something
|
|
113
|
+
# that LOOKS installed — the entry point present, its index listing
|
|
114
|
+
# guides, and every link in it dead. Nothing errors; the agent simply
|
|
115
|
+
# cannot follow its own instructions.
|
|
116
|
+
source = File.join(GEM_DIR, File.dirname(SKILL_REL))
|
|
117
|
+
results = entries(source).map do |rel|
|
|
118
|
+
copy(File.join(source, rel), File.join(dest, rel), overwrite: true)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
report(out, results, dest, "skill")
|
|
122
|
+
PASS
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Every path a template directory holds, relative, dotfiles included.
|
|
126
|
+
#
|
|
127
|
+
# A plain glob returns `["CLAUDE.md"]` and nothing else here: without
|
|
128
|
+
# FNM_DOTMATCH it does not match a leading dot, and this template is
|
|
129
|
+
# almost entirely dotfiles. `.` and `..` are rejected explicitly, which
|
|
130
|
+
# the flag makes necessary.
|
|
131
|
+
def entries(template)
|
|
132
|
+
Dir.glob(File.join(template, "**", "*"), File::FNM_DOTMATCH)
|
|
133
|
+
.reject { |path| [ ".", ".." ].include?(File.basename(path)) }
|
|
134
|
+
.select { |path| File.file?(path) }
|
|
135
|
+
.map { |path| path[(template.size + 1)..-1] }
|
|
136
|
+
.sort
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Where a template-relative path lands in the destination.
|
|
140
|
+
def target_rel(rel)
|
|
141
|
+
parts = rel.split("/")
|
|
142
|
+
parts[-1] = DOTFILES.fetch(parts.last, parts.last)
|
|
143
|
+
parts.join("/")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_tree(template, dest, overwrite: false)
|
|
147
|
+
entries(template).map do |rel|
|
|
148
|
+
copy(File.join(template, rel), File.join(dest, target_rel(rel)),
|
|
149
|
+
overwrite: overwrite, rel: target_rel(rel))
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# One file. Returns [ :written | :staged | :kept, path ] — the verb's
|
|
154
|
+
# report is built from these rather than printed here, so that the whole
|
|
155
|
+
# decision about what happened lives in one place.
|
|
156
|
+
#
|
|
157
|
+
# `rel` is the path *within the destination*, and it is what the `.okf/`
|
|
158
|
+
# exemption is asked about. Asking the absolute path would put every file
|
|
159
|
+
# of a repository that happens to live under a directory named `.okf` on
|
|
160
|
+
# the exempt side, which is a plausible enough path (`~/.okf/notes`) to be
|
|
161
|
+
# worth not getting wrong.
|
|
162
|
+
def copy(source, target, overwrite: false, rel: nil)
|
|
163
|
+
unless File.exist?(target)
|
|
164
|
+
write_atomically(target, File.binread(source), executable?(target))
|
|
165
|
+
return [ :written, target ]
|
|
166
|
+
end
|
|
167
|
+
return [ :written, target ] if overwrite && write_atomically(target, File.binread(source), executable?(target))
|
|
168
|
+
return [ :kept, target ] if inside_bundle?(rel) || File.binread(target) == File.binread(source)
|
|
169
|
+
|
|
170
|
+
staged = "#{target}#{SUFFIX}"
|
|
171
|
+
write_atomically(staged, File.binread(source), executable?(target))
|
|
172
|
+
[ :staged, staged ]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The `.okf/` exemption: a bundle that exists is a bundle already adopted,
|
|
176
|
+
# and eleven shadow files through someone's knowledge is litter, not a
|
|
177
|
+
# merge prompt. `rel` is nil for the single-file `skill` verb, which is
|
|
178
|
+
# gem-owned and overwrites before reaching here.
|
|
179
|
+
def inside_bundle?(rel)
|
|
180
|
+
rel.to_s.split("/").first == BundleRoot::DIR
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def executable?(target)
|
|
184
|
+
EXECUTABLE.any? { |rel| target.end_with?("/#{rel}") }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def write_atomically(target, content, executable)
|
|
188
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
189
|
+
tmp = "#{target}.okf-pro-tmp-#{Process.pid}"
|
|
190
|
+
File.binwrite(tmp, content)
|
|
191
|
+
File.chmod(executable ? 0o755 : 0o644, tmp)
|
|
192
|
+
File.rename(tmp, target)
|
|
193
|
+
true
|
|
194
|
+
ensure
|
|
195
|
+
File.unlink(tmp) if tmp && File.exist?(tmp)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# `setup` into a bundle whose root IS the repository root.
|
|
199
|
+
#
|
|
200
|
+
# Writing `.okf/index.md` beside it collides with nothing, so the naive
|
|
201
|
+
# generator succeeds and leaves two bundle roots in one directory — the
|
|
202
|
+
# exact state `Audit.ambiguous_layout` exists to report, where the three
|
|
203
|
+
# doors disagree until one of the two is retired. Refusing is the only
|
|
204
|
+
# honest answer: the migration is the adopter's to make, and it is one
|
|
205
|
+
# `git mv` they can see.
|
|
206
|
+
# Asked with `root_kind`, the same predicate `Audit.ambiguous_layout` asks,
|
|
207
|
+
# because this refusal cites that finding by name. It asked `bundle?` —
|
|
208
|
+
# `index.md` alone — while the finding needs index.md PLUS board.md or
|
|
209
|
+
# log.md, or an `okf_version` in the frontmatter. So every Jekyll section,
|
|
210
|
+
# Hugo directory and repo with an `index.md` README was refused, and told
|
|
211
|
+
# to `git mv` files that have nothing to do with OKF, for an ambiguity the
|
|
212
|
+
# audit would never have reported. A refusal that names a finding has to
|
|
213
|
+
# be answering the question that finding asks.
|
|
214
|
+
def flat_layout_refusal(dest)
|
|
215
|
+
return nil unless File.directory?(dest)
|
|
216
|
+
return nil if BundleRoot.bundle?(File.join(dest, BundleRoot::DIR))
|
|
217
|
+
return nil if BundleRoot.root_kind(dest).nil?
|
|
218
|
+
|
|
219
|
+
"okf pro setup — #{dest} is already an OKF bundle at its own root, and this scaffold " \
|
|
220
|
+
"puts the bundle under #{BundleRoot::DIR}/. Writing one here would leave two bundle roots " \
|
|
221
|
+
"in one directory, which the audit reports as an ambiguous layout and the three doors " \
|
|
222
|
+
"then disagree about. Move it first:\n " \
|
|
223
|
+
"mkdir #{BundleRoot::DIR} && git mv index.md log.md <your dirs> #{BundleRoot::DIR}/\n" \
|
|
224
|
+
"then run setup again."
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def report(out, results, dest, verb)
|
|
228
|
+
written = results.select { |kind, _| kind == :written }
|
|
229
|
+
staged = results.select { |kind, _| kind == :staged }
|
|
230
|
+
|
|
231
|
+
out.puts "okf pro #{verb} — #{dest}"
|
|
232
|
+
out.puts " #{written.size} written, #{staged.size} staged, #{results.size - written.size - staged.size} left alone"
|
|
233
|
+
return if staged.empty?
|
|
234
|
+
|
|
235
|
+
out.puts " These already existed and are yours, so the template's version is beside them:"
|
|
236
|
+
staged.each { |entry| out.puts " #{entry.last}" }
|
|
237
|
+
out.puts " Merge what you want from each, then delete it. #{ignore_note(dest)}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The last line of that report is a claim about the reader's own tree, so
|
|
241
|
+
# it is read from the tree rather than assumed.
|
|
242
|
+
#
|
|
243
|
+
# It was assumed, and it was false in exactly the case that matters: when
|
|
244
|
+
# `.gitignore` is itself the collision, the adopter's own file is what
|
|
245
|
+
# stays on disk and the template's — the one carrying `*#{SUFFIX}` — is
|
|
246
|
+
# what got staged beside it. Telling them it is already ignored is how the
|
|
247
|
+
# staged copies end up committed with nothing ever saying so, which is the
|
|
248
|
+
# failure the seeded `.gitignore` comment exists to name.
|
|
249
|
+
def ignore_note(dest)
|
|
250
|
+
gitignore = File.join(dest, ".gitignore")
|
|
251
|
+
ignored = File.file?(gitignore) && Pro.read_text(gitignore).include?("*#{SUFFIX}")
|
|
252
|
+
return "`.gitignore` already ignores *#{SUFFIX}." if ignored
|
|
253
|
+
|
|
254
|
+
"Add `*#{SUFFIX}` to `.gitignore` first, or they get committed."
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The door the other guards do not cover.
|
|
6
|
+
#
|
|
7
|
+
# `guard-verified` and `journal-guard` are PreToolUse on Edit/Write/
|
|
8
|
+
# MultiEdit — they read a `file_path` and the text being added. A shell
|
|
9
|
+
# command has neither: `cat > .okf/reference/x.md <<'EOF' … verified: …`
|
|
10
|
+
# writes the same bytes with no file_path to scope on and no new_string
|
|
11
|
+
# to scan, so every trust rule in this repo used to step aside for a tool
|
|
12
|
+
# the agent already has. That is the contract's forbidden case — not a
|
|
13
|
+
# gate that refused, a gate that was never asked.
|
|
14
|
+
#
|
|
15
|
+
# This cannot parse shell, and does not pretend to. It answers one
|
|
16
|
+
# narrow question — does this command look like it writes markdown
|
|
17
|
+
# inside a bundle? — and routes the answer to the owner, who can read
|
|
18
|
+
# the command. Asking is the honest verdict for a check that cannot
|
|
19
|
+
# decide: it costs one prompt when wrong, and unattended it fails
|
|
20
|
+
# closed, exactly like the attestation gate it backstops.
|
|
21
|
+
module ShellGuard
|
|
22
|
+
# Mutation, in the spellings a shell actually uses. Redirections and
|
|
23
|
+
# in-place editors first, then the file-movers.
|
|
24
|
+
MUTATORS = [
|
|
25
|
+
# `cat … > file`, `… >> file`, with two exclusions, both of them
|
|
26
|
+
# reads this used to call writes. A guard that fires on reads is a
|
|
27
|
+
# guard people switch off.
|
|
28
|
+
#
|
|
29
|
+
# Redirections to /dev/ are excluded: `2>/dev/null` is a read-only
|
|
30
|
+
# command discarding its stderr, and counting it as a write made
|
|
31
|
+
# every `grep … 2>/dev/null` in a repo that happens to contain
|
|
32
|
+
# markdown a prompt.
|
|
33
|
+
#
|
|
34
|
+
# An ASCII arrow is excluded by the lookbehind, because a `>` after
|
|
35
|
+
# `-` or `=` is not a position a redirection can occupy. Nothing
|
|
36
|
+
# anchored this before, so `-->` and `=>` read as writes — worst of
|
|
37
|
+
# all in a bundle, where `-->` closes the HTML comment the skill
|
|
38
|
+
# mandates on every keyed rule and is also mermaid's edge, and `=>`
|
|
39
|
+
# is in every quoted Ruby hash. The second alternative covers the
|
|
40
|
+
# doubled arrows (`->>`, `-->>`), whose trailing `>` is preceded by
|
|
41
|
+
# a `>` rather than by the `-` that gives it away.
|
|
42
|
+
%r{(?<![-=]|[-=]>)>{1,2}[[:blank:]]*(?!/dev/)[^|&\s]}, # cat … > file, … >> file
|
|
43
|
+
/\btee\b/,
|
|
44
|
+
/\b(?:sed|perl|ruby|python3?|node|awk)\b[^|;]*-i\b/, # in-place editors
|
|
45
|
+
/\b(?:cp|mv|rm|install|truncate|dd|rsync|ln)\b/,
|
|
46
|
+
/\b(?:File\.write|File\.open|open\([^)]*['"]w)/ # inline interpreter writes
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# A markdown path anywhere in the command, minus the quoting and
|
|
50
|
+
# separators a shell puts around it.
|
|
51
|
+
MARKDOWN = %r{[^\s'"`|;&()<>]*\.md\b}.freeze
|
|
52
|
+
|
|
53
|
+
# What separates one COMMAND from the next, for the one question below
|
|
54
|
+
# that has to look past the whole string.
|
|
55
|
+
#
|
|
56
|
+
# A single `|` is deliberately absent: a pipeline is one command, not
|
|
57
|
+
# two, and splitting on it put this gem's own redirect target in a
|
|
58
|
+
# segment of its own — `okf pro snapshot | tee -a .okf/log.md`, the
|
|
59
|
+
# prescribed way to append the Snapshot line, was recorded as friction.
|
|
60
|
+
# That is the good path counted as evidence against itself, which is the
|
|
61
|
+
# one failure the exclusion exists to prevent. `||` is still a separator
|
|
62
|
+
# and is listed first so the doubled form wins the alternation.
|
|
63
|
+
SEPARATORS = /&&|\|\||;|\n/.freeze
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
def check(event)
|
|
68
|
+
command = event.command
|
|
69
|
+
return [] if command.empty?
|
|
70
|
+
return [] unless MUTATORS.any? { |m| command.match?(m) }
|
|
71
|
+
|
|
72
|
+
# Scoped like every other guard: no bundle here, not this repo's
|
|
73
|
+
# business. The cwd is the only anchor a shell command offers —
|
|
74
|
+
# there is no file_path to walk up from — so this is the one check
|
|
75
|
+
# that must root through it.
|
|
76
|
+
root = BundleRoot.enclosing(event.cwd)
|
|
77
|
+
return [] if root.nil?
|
|
78
|
+
return [] unless touches_bundle?(command, root)
|
|
79
|
+
|
|
80
|
+
# Recorded here rather than at a hook event of its own, because
|
|
81
|
+
# `settings.json` is SEEDED: a new registration would never reach an
|
|
82
|
+
# adopter through `upgrade`, so friction has to ride a path already
|
|
83
|
+
# wired. This is the honest point for it — a markdown write inside the
|
|
84
|
+
# bundle, arriving through Bash, is by definition a thing the verbs did
|
|
85
|
+
# not cover. Own commands are skipped, or the good path would be
|
|
86
|
+
# counted as evidence against itself.
|
|
87
|
+
#
|
|
88
|
+
# It records INTENT: PreToolUse fires before the owner may deny.
|
|
89
|
+
unless own_write?(command)
|
|
90
|
+
Friction.record(root, "shell", Friction.classify_command(command), command.lines.first.to_s.strip[0, 200])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{ "ask" => "This shell command looks like it writes markdown inside the bundle:\n " \
|
|
94
|
+
"#{command.lines.first.to_s.strip}\n" \
|
|
95
|
+
"Shell writes bypass the trust guards — nothing checked this for a forged " \
|
|
96
|
+
"'verified:' block or an edit to a past journal day. Approve only if you " \
|
|
97
|
+
"have read the command and it does neither; otherwise deny and use Edit or " \
|
|
98
|
+
"Write, which the guards can see." }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether the WRITE belongs to this gem — not whether the command mentions
|
|
102
|
+
# it. `Friction.own_command?` exists so the good path is not counted as
|
|
103
|
+
# evidence against itself (`okf pro snapshot >> .okf/log.md` is the
|
|
104
|
+
# prescribed way to add the Snapshot line), and asking it of the whole
|
|
105
|
+
# string let a mention anywhere suppress the record: `okf pro state &&
|
|
106
|
+
# cat notes.md > .okf/board.md` prompted the owner and counted nothing.
|
|
107
|
+
# An agent chaining a read with a write is exactly the pattern that
|
|
108
|
+
# produces it, so the under-count was in the commonest shape.
|
|
109
|
+
#
|
|
110
|
+
# So it is asked of the SEGMENT that carries the mutator. A segment with
|
|
111
|
+
# no mutator is nobody's write — `cd repo && okf pro promote alpha` is
|
|
112
|
+
# still this gem's — and a mutator in a segment this gem does not own is
|
|
113
|
+
# a hand-write whatever else the line says.
|
|
114
|
+
#
|
|
115
|
+
# Splitting on separators is NOT shell parsing and may not become one:
|
|
116
|
+
# a separator inside quotes mis-splits, and the only consequence of that
|
|
117
|
+
# is a telemetry row. The PROMPT above is decided from the whole command
|
|
118
|
+
# and is unaffected, so nothing about the guard's safety rides on this.
|
|
119
|
+
#
|
|
120
|
+
# The confessed residue, since a bounded error is only bounded if it is
|
|
121
|
+
# written down: a `;` or `&&` inside quoted content splits a command that
|
|
122
|
+
# is genuinely this gem's, and the fragment carrying the mutator word is
|
|
123
|
+
# then read as somebody else's write; and a capture whose TEXT names
|
|
124
|
+
# `board.md` trips the laundering guard above. Both over-count. That is
|
|
125
|
+
# the lesser error — an over-count points a maintainer at a verb that
|
|
126
|
+
# already exists, while the under-counts these replaced hid the commonest
|
|
127
|
+
# shape there is and the worst one there is — and neither can refuse
|
|
128
|
+
# anything.
|
|
129
|
+
def own_write?(command)
|
|
130
|
+
segments = command.to_s.split(SEPARATORS)
|
|
131
|
+
return false unless segments.any? { |segment| Friction.own_command?(segment) }
|
|
132
|
+
# A pipeline is one command, and that must not become a way to launder
|
|
133
|
+
# a hand-write: `okf pro board | sed s/a/b/ > .okf/board.md` begins with
|
|
134
|
+
# this gem and ends by regenerating the board, which is failure mode 07
|
|
135
|
+
# by name. No verb pipes into a path a verb covers — `snapshot` has no
|
|
136
|
+
# `--write` and appends to `log.md`, which no verb covers — so an own
|
|
137
|
+
# command aimed at the board is friction whatever produced the bytes.
|
|
138
|
+
return false if Friction.classify_command(command) == "board.md"
|
|
139
|
+
|
|
140
|
+
segments.none? do |segment|
|
|
141
|
+
!Friction.own_command?(segment) && MUTATORS.any? { |m| segment.match?(m) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The bundle by name is enough on its own. A markdown path is enough
|
|
146
|
+
# only if it could be in the bundle: relative — resolved against a cwd
|
|
147
|
+
# already known to be inside one — or absolute and under the root. An
|
|
148
|
+
# absolute path elsewhere is not this repo's markdown, and treating a
|
|
149
|
+
# gem's CHANGELOG.md as a bundle write is how the prompt stopped
|
|
150
|
+
# carrying information.
|
|
151
|
+
#
|
|
152
|
+
# Relative paths stay conservative rather than being resolved against
|
|
153
|
+
# the cwd, because a `cd` earlier in the command would make that
|
|
154
|
+
# resolution a guess, and this guard asks when it cannot decide.
|
|
155
|
+
def touches_bundle?(command, root)
|
|
156
|
+
return true if command.match?(/#{Regexp.escape(BundleRoot::DIR)}\b/)
|
|
157
|
+
|
|
158
|
+
command.scan(MARKDOWN).any? do |path|
|
|
159
|
+
!path.start_with?("/") || path.start_with?("#{root}/")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# Rule 2's counters, derived. This is derivation returned as a *checker*:
|
|
6
|
+
# it computes the mechanical line and verifies the one a person appended —
|
|
7
|
+
# it never writes one. Agent Drift killed the generator (a regenerated view
|
|
8
|
+
# drops a task silently); a checker that disagrees out loud has no way to
|
|
9
|
+
# drop anything silently, which is why it gets to exist first.
|
|
10
|
+
module Snapshot
|
|
11
|
+
# The line's vocabulary, in the order the line reads. Parse patterns are
|
|
12
|
+
# anchored on the field names so a hand-typed line with extra spacing
|
|
13
|
+
# still parses, and a line missing a field says so as nil rather than
|
|
14
|
+
# borrowing a neighbour's number. "in flight" requires the slash so the
|
|
15
|
+
# phrase inside "deadlines within 7d not in flight" cannot satisfy it.
|
|
16
|
+
#
|
|
17
|
+
# `oldest` takes an optional sign, and it is the only field that can:
|
|
18
|
+
# every other counter is a count, and `oldest` is a SUBTRACTION. A
|
|
19
|
+
# capture dated in the future — one mistyped year — gives a negative age,
|
|
20
|
+
# `render` wrote `oldest -365d`, and a pattern demanding a digit straight
|
|
21
|
+
# after `oldest ` read that as nothing at all. `verify` then disagreed
|
|
22
|
+
# with the line `render` had just produced, so the stop gate refused and
|
|
23
|
+
# offered as the fix the very line it was refusing. Pasting it refused
|
|
24
|
+
# again: a deadlock with no self-service exit.
|
|
25
|
+
#
|
|
26
|
+
# The age is NOT clamped to zero, deliberately. Clamping would make the
|
|
27
|
+
# gate self-consistent by making the counter lie, and `oldest 0d` on a
|
|
28
|
+
# line nobody can explain is the quiet-wrong-number failure this whole
|
|
29
|
+
# module exists to refuse. A negative age is the honest reading of a
|
|
30
|
+
# future-dated capture, and it is visible in the log where a person will
|
|
31
|
+
# ask about it.
|
|
32
|
+
PATTERNS = {
|
|
33
|
+
"inbox" => /inbox (\d+)/,
|
|
34
|
+
"oldest" => /oldest (-?\d+)d/,
|
|
35
|
+
"in flight" => %r{in flight (\d+)/\d+},
|
|
36
|
+
"cap" => %r{in flight \d+/(\d+)},
|
|
37
|
+
"waiting" => /waiting (\d+)/,
|
|
38
|
+
"past chase" => /\((\d+) past chase\)/,
|
|
39
|
+
"backlog" => /backlog (\d+)/,
|
|
40
|
+
"to read" => /to read (\d+)/,
|
|
41
|
+
"unverified briefings" => /unverified briefings (\d+)/,
|
|
42
|
+
"conflicts open" => /conflicts open (\d+)/,
|
|
43
|
+
"deadlines within 7d not in flight" => /deadlines within 7d not in flight (\d+)/,
|
|
44
|
+
"projects with 0 concepts" => /projects with 0 concepts (\d+)/
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# `board:` and `concepts:` are the same courtesy Pairing.failures
|
|
50
|
+
# extends: a caller that already paid for the read hands it over. The
|
|
51
|
+
# stop gate reads the board and parses the bundle once for all of its
|
|
52
|
+
# checks; without the seam it paid twice per Stop.
|
|
53
|
+
def counters(root, today: Date.today, board: nil, concepts: nil)
|
|
54
|
+
board ||= Pro.read_text(File.join(root, "board.md"))
|
|
55
|
+
concepts ||= ::OKF::Bundle::Reader.read(root).concepts
|
|
56
|
+
# One strip for all twelve counters — every Board.count call was
|
|
57
|
+
# re-running the whole comment strip, ~10 times per Stop.
|
|
58
|
+
vis = Board.visible(board)
|
|
59
|
+
budget = Board.budget(vis)
|
|
60
|
+
inbox = Board.visible_section_lines(vis, "Inbox")
|
|
61
|
+
waiting = Board.visible_section_lines(vis, "Waiting")
|
|
62
|
+
ages = inbox.map { |l| Board.line_date(l) }.compact
|
|
63
|
+
.map { |d| (today - d).to_i }
|
|
64
|
+
chases = waiting.map { |l| Board.chase_date(l) }.compact
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
"inbox" => inbox.size,
|
|
68
|
+
"oldest" => ages.max || 0,
|
|
69
|
+
"in flight" => Board.visible_section_lines(vis, "In flight").size,
|
|
70
|
+
"cap" => budget ? budget.cap : 0,
|
|
71
|
+
"waiting" => waiting.size,
|
|
72
|
+
"past chase" => chases.count { |d| d < today },
|
|
73
|
+
"backlog" => Board.visible_section_lines(vis, "Backlog").size,
|
|
74
|
+
"to read" => Board.visible_section_lines(vis, "To read").size,
|
|
75
|
+
"unverified briefings" => Pairing.unverified_ids(concepts).size,
|
|
76
|
+
"conflicts open" => conflicts(vis),
|
|
77
|
+
"deadlines within 7d not in flight" => looming_deadlines(vis, today).size,
|
|
78
|
+
"projects with 0 concepts" => empty_projects(root).size
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def line(root, today: Date.today, board: nil, concepts: nil)
|
|
83
|
+
render(counters(root, today: today, board: board, concepts: concepts))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render(c)
|
|
87
|
+
"* **Snapshot**: inbox #{c["inbox"]} (oldest #{c["oldest"]}d) " \
|
|
88
|
+
"· in flight #{c["in flight"]}/#{c["cap"]} " \
|
|
89
|
+
"· waiting #{c["waiting"]} (#{c["past chase"]} past chase) " \
|
|
90
|
+
"· backlog #{c["backlog"]} · to read #{c["to read"]} " \
|
|
91
|
+
"· unverified briefings #{c["unverified briefings"]} " \
|
|
92
|
+
"· conflicts open #{c["conflicts open"]} " \
|
|
93
|
+
"· deadlines within 7d not in flight #{c["deadlines within 7d not in flight"]} " \
|
|
94
|
+
"· projects with 0 concepts #{c["projects with 0 concepts"]}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse(line)
|
|
98
|
+
PATTERNS.transform_values do |pattern|
|
|
99
|
+
match = line.to_s.match(pattern)
|
|
100
|
+
match && match[1].to_i
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The appended line against the bundle it summarises. Field by field, so
|
|
105
|
+
# the refusal names what drifted instead of waving at the whole line —
|
|
106
|
+
# and the recomputed line is in the message, because the fix is a paste.
|
|
107
|
+
def verify(root, line, today: Date.today, board: nil, concepts: nil)
|
|
108
|
+
want = counters(root, today: today, board: board, concepts: concepts)
|
|
109
|
+
got = parse(line)
|
|
110
|
+
wrong = want.reject { |key, value| got[key] == value }
|
|
111
|
+
return [] if wrong.empty?
|
|
112
|
+
|
|
113
|
+
detail = wrong.map { |key, value| "#{key} is #{value}, the line says #{got[key] || "nothing"}" }
|
|
114
|
+
[ "— the Snapshot line disagrees with the bundle it summarises: #{detail.join("; ")}.\n " \
|
|
115
|
+
"The mechanical line, recomputed:\n #{render(want)}" ]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Conflict lines are dated captures wherever they sit — Inbox by rule,
|
|
119
|
+
# but a line that migrated to Backlog is still an open conflict.
|
|
120
|
+
def conflicts(board)
|
|
121
|
+
# visible() is a fixpoint, so a caller handing over already-visible
|
|
122
|
+
# text (counters does) costs one no-op scan, not a second strip.
|
|
123
|
+
Board.visible(board).each_line.count { |l| l.start_with?("- ") && l.include?("Resolve:") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Crack 2's confession: deadlines due within the window — or already due
|
|
127
|
+
# — that share no project link with any in-flight line. A deadline line
|
|
128
|
+
# with no link cannot be paired, so it counts: the board cannot show
|
|
129
|
+
# anyone is on it, and "probably someone is" is not a counter.
|
|
130
|
+
def looming_deadlines(board, today, window: 7)
|
|
131
|
+
vis = Board.visible(board)
|
|
132
|
+
covered = Board.visible_section_lines(vis, "In flight").flat_map { |l| project_slugs(l) }
|
|
133
|
+
Board.visible_section_lines(vis, "Deadlines").select do |line|
|
|
134
|
+
date = Board.line_date(line)
|
|
135
|
+
next false unless date && date <= today + window
|
|
136
|
+
|
|
137
|
+
(project_slugs(line) & covered).empty?
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def project_slugs(line)
|
|
142
|
+
Board.targets(line).map { |t| t[%r{\A/projects/([^/\s]+)}i, 1]&.downcase }.compact
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# An open project holding nothing but its own index: work happening with
|
|
146
|
+
# no knowledge landing — the blind spot the structure itself created.
|
|
147
|
+
def empty_projects(root)
|
|
148
|
+
Pairing.open_projects(root).select do |name|
|
|
149
|
+
Dir.glob(File.join(root, "projects", name, "**", "*.md"))
|
|
150
|
+
.reject { |f| File.basename(f) == "index.md" }
|
|
151
|
+
.empty?
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|