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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/contract/containment-directions.md +107 -0
  3. data/.okf/contract/exit-codes.md +42 -0
  4. data/.okf/contract/gates-only-at-the-hook-door.md +39 -0
  5. data/.okf/contract/index.md +10 -0
  6. data/.okf/contract/silent-skips.md +78 -0
  7. data/.okf/contract/telemetry-does-not-lie.md +218 -0
  8. data/.okf/contract/the-contract.md +43 -0
  9. data/.okf/design/a-comment-is-not-an-implementation.md +85 -0
  10. data/.okf/design/a-rule-you-can-walk-past.md +68 -0
  11. data/.okf/design/derivation-that-writes.md +161 -0
  12. data/.okf/design/failure-modes.md +43 -0
  13. data/.okf/design/index.md +18 -0
  14. data/.okf/design/lineage.md +95 -0
  15. data/.okf/design/structure-laws.md +46 -0
  16. data/.okf/design/the-residue.md +44 -0
  17. data/.okf/design/three-laws.md +71 -0
  18. data/.okf/design/three-pillars.md +48 -0
  19. data/.okf/index.md +31 -0
  20. data/.okf/log.md +74 -0
  21. data/.okf/scaffold/collisions-and-refusals.md +76 -0
  22. data/.okf/scaffold/index.md +8 -0
  23. data/.okf/scaffold/no-date-ships.md +41 -0
  24. data/.okf/scaffold/ownership-not-subject.md +63 -0
  25. data/.okf/scaffold/the-adopters-manual.md +114 -0
  26. data/.okf/seam/bundler-scoping.md +45 -0
  27. data/.okf/seam/identity-not-existence.md +67 -0
  28. data/.okf/seam/index.md +8 -0
  29. data/.okf/seam/the-wrapper.md +42 -0
  30. data/.okf/seam/three-fail-opens.md +44 -0
  31. data/.okf/testing/drills-over-units.md +41 -0
  32. data/.okf/testing/fixture-is-a-client.md +35 -0
  33. data/.okf/testing/index.md +4 -0
  34. data/.okf/trust/index.md +7 -0
  35. data/.okf/trust/read-owed-rule.md +45 -0
  36. data/.okf/trust/scalar-verified.md +48 -0
  37. data/CHANGELOG.md +187 -0
  38. data/LICENSE.txt +201 -0
  39. data/NOTICE +10 -0
  40. data/README.md +383 -0
  41. data/lib/okf/plugin.rb +148 -0
  42. data/lib/okf/pro/attestation.rb +62 -0
  43. data/lib/okf/pro/audit.rb +161 -0
  44. data/lib/okf/pro/board/edit.rb +201 -0
  45. data/lib/okf/pro/board.rb +291 -0
  46. data/lib/okf/pro/budget.rb +144 -0
  47. data/lib/okf/pro/bundle_root.rb +241 -0
  48. data/lib/okf/pro/cli.rb +914 -0
  49. data/lib/okf/pro/closing.rb +206 -0
  50. data/lib/okf/pro/conformance.rb +127 -0
  51. data/lib/okf/pro/conserve.rb +94 -0
  52. data/lib/okf/pro/event.rb +95 -0
  53. data/lib/okf/pro/friction.rb +262 -0
  54. data/lib/okf/pro/guards.rb +92 -0
  55. data/lib/okf/pro/log/edit.rb +68 -0
  56. data/lib/okf/pro/log.rb +162 -0
  57. data/lib/okf/pro/pairing.rb +271 -0
  58. data/lib/okf/pro/reconcile.rb +76 -0
  59. data/lib/okf/pro/records.rb +90 -0
  60. data/lib/okf/pro/scaffold.rb +258 -0
  61. data/lib/okf/pro/shell_guard.rb +164 -0
  62. data/lib/okf/pro/snapshot.rb +156 -0
  63. data/lib/okf/pro/state.rb +174 -0
  64. data/lib/okf/pro/target.rb +91 -0
  65. data/lib/okf/pro/template/gem/.claude/hooks/run +119 -0
  66. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/SKILL.md +233 -0
  67. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/attribution.md +46 -0
  68. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/board.md +55 -0
  69. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/closing.md +21 -0
  70. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/frontmatter.md +73 -0
  71. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/okf-rules.md +25 -0
  72. data/lib/okf/pro/template/gem/.githooks/pre-commit +68 -0
  73. data/lib/okf/pro/template/gem/.github/workflows/okf-pro.yml +69 -0
  74. data/lib/okf/pro/template/seed/.claude/settings.json +24 -0
  75. data/lib/okf/pro/template/seed/.okf/areas/corpus.md +73 -0
  76. data/lib/okf/pro/template/seed/.okf/areas/index.md +6 -0
  77. data/lib/okf/pro/template/seed/.okf/board.md +21 -0
  78. data/lib/okf/pro/template/seed/.okf/glossary/index.md +13 -0
  79. data/lib/okf/pro/template/seed/.okf/index.md +30 -0
  80. data/lib/okf/pro/template/seed/.okf/journal/index.md +11 -0
  81. data/lib/okf/pro/template/seed/.okf/learnings/index.md +13 -0
  82. data/lib/okf/pro/template/seed/.okf/log.md +10 -0
  83. data/lib/okf/pro/template/seed/.okf/projects/index.md +23 -0
  84. data/lib/okf/pro/template/seed/.okf/reference/index.md +16 -0
  85. data/lib/okf/pro/template/seed/.okf/roadmap.md +33 -0
  86. data/lib/okf/pro/template/seed/.tmp/.gitkeep +0 -0
  87. data/lib/okf/pro/template/seed/CLAUDE.md +61 -0
  88. data/lib/okf/pro/template/seed/README.md +488 -0
  89. data/lib/okf/pro/template/seed/gitignore +17 -0
  90. data/lib/okf/pro/version.rb +7 -0
  91. data/lib/okf/pro/writes.rb +560 -0
  92. data/lib/okf/pro.rb +198 -0
  93. 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