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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # Rule 3 — in flight is a budget.
6
+ #
7
+ # Two refusals, and the second matters as much as the first. Over the cap is
8
+ # overload. A header that disagrees with the section under it is worse than
9
+ # no header: the board's whole job is to be the one page you can trust at a
10
+ # glance, and a budget face that lies costs more than an absent one.
11
+ module Budget
12
+ # Five working days without a journal link is the dormancy window — the
13
+ # budget's own question, not a verdict.
14
+ #
15
+ # It is NOT tunable, and the comment here used to say it was ("tuned in the
16
+ # skill's Rule 3"), which was false in the direction that costs the most:
17
+ # the skill states the number in prose, an adopter who changed it there
18
+ # would see nothing happen, and the gate would keep asking on the old
19
+ # window while the written rule said otherwise. Nobody has asked for a
20
+ # knob; what the situation needed was for the two statements to be one
21
+ # fact. `test/unit/dormancy_window_test.rb` pins the skill's number to this
22
+ # constant, so changing it here fails until the skill follows.
23
+ DORMANCY_DAYS = 5
24
+
25
+ module_function
26
+
27
+ def cap_check(target)
28
+ return [] if target.nil?
29
+ return [] unless target.rel == "board.md"
30
+ return [] unless target.exist?("board.md")
31
+
32
+ board = target.read("board.md")
33
+ # Grammar rides along at the write-time door on purpose: the stray
34
+ # bullet and the unreadable date are exactly the lines the counters
35
+ # below cannot see, and a cap check that stays quiet while its own
36
+ # input is partly invisible has already lost the argument. Audit
37
+ # and stop gate run the same pass; this door ran only the counters,
38
+ # so the one moment the writer was still holding the pen was the
39
+ # one moment nothing spoke.
40
+ check_text(board) + Board.grammar(board)
41
+ end
42
+
43
+ def check_text(board)
44
+ board = Board.visible(board)
45
+ budget = Board.budget(board)
46
+ unless budget
47
+ return [ "board.md lost its 'In flight: k/CAP' header — Rule 3's visible budget. Restore it first." ]
48
+ end
49
+
50
+ count = Board.count(board, "In flight")
51
+
52
+ if count > budget.cap
53
+ return [ "RULE 3 — #{count} in flight against a cap of #{budget.cap}. Promotion requires demotion, " \
54
+ "or a visible renegotiation (new cap in the header; renegotiations are journal-worthy)." ]
55
+ end
56
+
57
+ if budget.declared != count
58
+ return [ "Header claims #{budget.declared} in flight; the section holds #{count}. " \
59
+ "Fix the header — a wrong budget face is worse than none." ]
60
+ end
61
+
62
+ []
63
+ end
64
+
65
+ # The dormancy questions: in-flight demands no journal entry has linked
66
+ # in DORMANCY_DAYS working days. Questions, never verdicts — "still in
67
+ # flight, or backlog pretending? Either answer is fine; holding a slot
68
+ # without moving is not."
69
+ #
70
+ # Two silences are deliberate. A journal younger than the window stays
71
+ # quiet — a bundle in its first week cannot be dormant, only new. And a
72
+ # demand whose promotion was journaled (promotions are journal-worthy)
73
+ # is linked from day one, so the fresh-promotion false positive is
74
+ # already covered by the discipline this question serves.
75
+ def dormancy_questions(root, today: Date.today, board: nil)
76
+ board_path = File.join(root, "board.md")
77
+ return [] if board.nil? && !File.exist?(board_path)
78
+
79
+ entries = journal_entries(root)
80
+ return [] if entries.empty?
81
+
82
+ start = window_start(today)
83
+ return [] if entries.map(&:first).min > start
84
+
85
+ # Contained: the root is in hand, and a journal entry symlinked in from
86
+ # outside the bundle is not this bundle's record of a day. One that
87
+ # escapes is dropped rather than raising — dormancy is a question, not a
88
+ # gate, and an unreadable entry means "no evidence of work", which is
89
+ # the answer that keeps the question being asked.
90
+ recent = entries.select { |day, _| day >= start }
91
+ .map { |_, path| safely(root, path) }
92
+ .join("\n").downcase
93
+
94
+ lines = Board.section_lines(board || safely(root, board_path), "In flight")
95
+ questions = lines.map do |line|
96
+ slugs = Snapshot.project_slugs(line)
97
+ next if slugs.empty?
98
+ next if slugs.any? { |slug| recent.include?("/projects/#{slug}") }
99
+
100
+ "Rule 3, dormancy — no journal entry has linked /projects/#{slugs.first}/ in " \
101
+ "#{DORMANCY_DAYS} working days: still in flight, or backlog pretending?"
102
+ end.compact
103
+
104
+ # Law 2: the check confesses its own blind spot rather than skipping it
105
+ # in silence — a demand with no project link is one dormancy cannot see.
106
+ unlinked = lines.count { |line| Snapshot.project_slugs(line).empty? }
107
+ questions << "Note: #{unlinked} in-flight line(s) carry no /projects/ link — dormancy cannot see them." if unlinked.positive?
108
+
109
+ questions
110
+ end
111
+
112
+ def safely(root, path)
113
+ Pro.read_contained(root, path)
114
+ rescue ::OKF::Path::Error, ::Errno::ENOENT
115
+ ""
116
+ end
117
+
118
+ def journal_entries(root)
119
+ Dir.glob(File.join(root, "journal", "*.md")).map do |path|
120
+ match = File.basename(path).match(/\A(\d{4}-\d{2}-\d{2})\.md\z/)
121
+ next unless match
122
+
123
+ day = Board.parse_date(match[1])
124
+ [ day, path ] if day
125
+ end.compact
126
+ end
127
+
128
+ # Walk back from today until the window holds DORMANCY_DAYS working days.
129
+ # Weekends extend the window rather than counting against it — legitimate
130
+ # stillness is what keeps the dormancy alarm honest.
131
+ def window_start(today, days = DORMANCY_DAYS)
132
+ date = today
133
+ counted = 0
134
+ loop do
135
+ counted += 1 if (1..5).cover?(date.wday)
136
+ break if counted >= days
137
+
138
+ date -= 1
139
+ end
140
+ date
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # Where the bundle is, given where the agent is.
6
+ #
7
+ # The hook event carries a working directory, which is the repository root
8
+ # — not the bundle. They stopped being the same thing when the knowledge
9
+ # moved into `.okf/` and the repository kept README, CLAUDE.md, the
10
+ # checker and CI at the top. Every check that reads the corpus has to make
11
+ # that hop, and making it in one place is the difference between one rule
12
+ # and six copies of it drifting apart.
13
+ #
14
+ # Both layouts resolve, nested first. An adopter who prefers the repository
15
+ # root to *be* the bundle — the shape this template shipped with — is not
16
+ # broken by the change, and neither are the fixtures.
17
+ module BundleRoot
18
+ DIR = ".okf"
19
+
20
+ module_function
21
+
22
+ # The bundle root, or nil when `start` is not a repository holding one.
23
+ # Nil is a real answer: a check that cannot find a bundle has nothing to
24
+ # say about it, which is different from finding one and approving of it.
25
+ def resolve(start)
26
+ base = File.expand_path(start.to_s)
27
+
28
+ nested = File.join(base, DIR)
29
+ return nested if bundle?(nested)
30
+ return base if bundle?(base)
31
+
32
+ nil
33
+ end
34
+
35
+ # The bundle that contains `path`, found by walking up from the file
36
+ # itself — or nil. The discriminator is level_root/root_kind below:
37
+ # the innermost strong or anchored root wins outright, and plain weak
38
+ # candidates resolve by fencing, so journal/index.md and friends do
39
+ # not stop the walk. This exists because scoping trust
40
+ # guards through the event's cwd was a hole: cwd is wherever the
41
+ # session happens to sit, resolve() never walks up, and a session
42
+ # parked in a subdirectory made the guards find no bundle and
43
+ # silently disarm.
44
+ # Fenced at the file's own repository, exactly as enclosing() is: a
45
+ # bundle above the session's repo is somebody else's, and a file
46
+ # inside a nested repository must not summon the guards of the tree
47
+ # above it. Outside any repository the walk runs to the filesystem
48
+ # root — a file physically inside a bundle's tree is governed by it,
49
+ # which is the walk's whole claim; the directory walk cannot say the
50
+ # same of a cwd, which is why enclosing() refuses to walk unfenced.
51
+ #
52
+ # ONE deliberate divergence from enclosing(): `probe_nested: false`.
53
+ # A root that does not contain the file is no root of it, so this
54
+ # walk never adopts a sibling `.okf`. In the one layout where that
55
+ # matters — a directory that is BOTH a flat root and the parent of
56
+ # an `.okf` root — this door governs a file outside `.okf` by the
57
+ # flat root while the stop gate and the audit govern `.okf`. That
58
+ # layout is ambiguous at its source (two bundles, one directory),
59
+ # the alternative was guards that skipped the file in silence, and
60
+ # Audit.ambiguous_layout reports it rather than leaving the doors to
61
+ # disagree quietly.
62
+ def containing(path)
63
+ return nil if path.to_s.empty?
64
+
65
+ dir = File.dirname(File.expand_path(path))
66
+ top = repo_root(dir)
67
+ weak = nil
68
+ until dir == File.dirname(dir)
69
+ kind, found = level_root(dir, probe_nested: false)
70
+ case kind
71
+ when :strong, :anchored
72
+ return found
73
+ when :weak
74
+ # Fenced, the outermost weak directory inside the fence is the
75
+ # bundle — a real root contains its directories. Unfenced there
76
+ # is no "outermost inside" to speak of, and climbing toward the
77
+ # filesystem root adopted a stranger's index+log over the
78
+ # bundle the file actually lives in — so the nearest weak root
79
+ # wins and the walk stops guessing upward.
80
+ return found if top.nil?
81
+
82
+ weak = found
83
+ end
84
+ break if dir == top
85
+
86
+ dir = File.dirname(dir)
87
+ end
88
+ weak
89
+ end
90
+
91
+ # The bundle governing a working directory, found by walking up from
92
+ # it. Same classifier, same tiers as containing(); the only
93
+ # difference is the no-repository rule — a cwd, unlike a file, is
94
+ # not "inside" anything in a way that justifies climbing, so with no
95
+ # fence the look is single-level and cannot adopt anything above the
96
+ # directory it was handed.
97
+ def enclosing(start)
98
+ dir = File.expand_path(start.to_s)
99
+ top = repo_root(dir)
100
+ if top.nil?
101
+ kind, found = level_root(dir)
102
+ return kind ? found : nil
103
+ end
104
+
105
+ weak = nil
106
+ loop do
107
+ kind, found = level_root(dir)
108
+ case kind
109
+ when :strong, :anchored
110
+ return found
111
+ when :weak
112
+ weak = found
113
+ end
114
+ break if dir == top
115
+
116
+ dir = File.dirname(dir)
117
+ end
118
+ weak
119
+ end
120
+
121
+ # The first ancestor (inclusive) holding a .git entry — a directory
122
+ # for ordinary repositories, a file for worktrees and submodules.
123
+ def repo_root(dir)
124
+ until dir == File.dirname(dir)
125
+ return dir if File.exist?(File.join(dir, ".git"))
126
+
127
+ dir = File.dirname(dir)
128
+ end
129
+ nil
130
+ end
131
+
132
+ # The strongest proof at one level, ONCE — both walks consume this,
133
+ # so the ordering cannot drift between them (it did, and the doors
134
+ # governed different bundles). `probe_nested:` is the walks' one
135
+ # legitimate difference: enclosing roots a CWD, and the bundle
136
+ # governing a session may sit beside it at any ancestor level, so
137
+ # each level's `.okf` is probed. containing roots a FILE, and a
138
+ # root that does not contain the file is no root of it — the
139
+ # sibling probe adopted repo/.okf for repo/reference/x.md, the rel
140
+ # path nil'd out, and every write-time guard skipped the edit in
141
+ # silence. A file actually inside `.okf` meets it as the dir itself
142
+ # on the way up, so containing loses nothing by not probing.
143
+ # Three tiers, nested .okf (when probed) before the level itself:
144
+ # :strong — okf_version in the index frontmatter. Innermost wins,
145
+ # immediately: only a root carries it.
146
+ # :anchored — a weak proof in a directory literally named `.okf`.
147
+ # The name is the convention's own marker, so an inner
148
+ # nested bundle beats outer coincidence — outer-weak-
149
+ # wins let a stray index+log at the repo root shadow a
150
+ # real frontmatterless .okf and silently disarm every
151
+ # write-time guard.
152
+ # :weak — index.md plus any core file (board.md or log.md) in
153
+ # an ordinarily named directory. Resolution differs by
154
+ # fencing; see the walks.
155
+ def level_root(dir, probe_nested: true)
156
+ nested = File.join(dir, DIR)
157
+ nested_kind = probe_nested ? root_kind(nested) : nil
158
+ return [ :strong, nested ] if nested_kind == :strong
159
+
160
+ dir_kind = root_kind(dir)
161
+ return [ :strong, dir ] if dir_kind == :strong
162
+ return [ :anchored, nested ] if nested_kind == :weak
163
+
164
+ case dir_kind
165
+ when :weak
166
+ File.basename(dir) == DIR ? [ :anchored, dir ] : [ :weak, dir ]
167
+ end
168
+ end
169
+
170
+ # One stat ladder per directory: :strong, :weak, or nil. The weak
171
+ # proof accepts ONE core file, not the whole skeleton — demanding
172
+ # both left a mid-bootstrap bundle (index and board down, log.md not
173
+ # yet written) unguarded while its first concepts landed. An
174
+ # index.md truly alone remains ambiguous with a directory index and
175
+ # is never adopted: the scaffold writes the skeleton in one move,
176
+ # and a walk that guessed would re-open the bogus-adoption hole.
177
+ def root_kind(dir)
178
+ return nil unless File.file?(File.join(dir, "index.md"))
179
+ return :strong if root_index?(dir)
180
+
181
+ return unless File.file?(File.join(dir, "board.md")) || File.file?(File.join(dir, "log.md"))
182
+
183
+ :weak
184
+ end
185
+
186
+ def bundle?(dir)
187
+ File.file?(File.join(dir, "index.md"))
188
+ end
189
+
190
+ # A fence exactly as the okf gem parses one — dashes at column zero,
191
+ # trailing blanks tolerated, nothing else. One rule, not two that
192
+ # drift: strip-based matching briefly accepted indented fences here,
193
+ # which ended the scan at a "---" inside a YAML block scalar (real
194
+ # root unrecognised, guards re-rooted through the cwd fallback) and
195
+ # recognised roots the gem itself refuses to parse. No BOM tolerance
196
+ # for the same reason — the gem raises on one, and a "root" whose
197
+ # corpus every later check crashes on is not a root.
198
+ FENCE = /\A---[[:blank:]]*\z/.freeze
199
+
200
+ # The root index declares `okf_version:` in its frontmatter. Matching
201
+ # the bare token anywhere in the file was a hole: directory indexes are
202
+ # free-form prose, this bundle is *about* OKF, and a journal index that
203
+ # mentioned okf_version in a sentence would have stopped the walk early,
204
+ # mis-rooted the bundle, and disarmed the journal guard through a rel
205
+ # path that no longer started with journal/. Only the key, only inside
206
+ # a COMPLETE leading frontmatter block, counts — the closing fence is
207
+ # required, because a mangled block that never closes must not let a
208
+ # column-zero mention in prose adopt the directory. The block is read
209
+ # once (no re-enumeration racing an agent's write between two opens)
210
+ # through Pro.read_text, and bounded: frontmatter whose closing fence
211
+ # sits past line 101 is not recognised, a stated limit rather than a
212
+ # silent one, and pinned by a test.
213
+ def root_index?(dir)
214
+ index = File.join(dir, "index.md")
215
+ return false unless File.file?(index)
216
+
217
+ # Streamed, not slurped: this runs per ancestor on every tool-use
218
+ # event, and read_text pulled the whole file in before the 101-line
219
+ # bound was applied. The per-line scrub matches read_text's
220
+ # defense — bytes in, forced UTF-8, invalid sequences replaced.
221
+ lines = []
222
+ File.foreach(index, mode: "rb") do |l|
223
+ lines << l.force_encoding(Encoding::UTF_8).scrub.chomp
224
+ break if lines.size >= 101
225
+ end
226
+ return false unless lines.first&.match?(FENCE)
227
+
228
+ body = lines.drop(1)
229
+ close = body.index { |l| l.match?(FENCE) }
230
+ return false if close.nil?
231
+
232
+ body.first(close).any? { |l| l.match?(/\Aokf_version[[:blank:]]*:/) }
233
+ rescue Errno::ENOENT
234
+ # The file passed File.file? and vanished before the read — an agent
235
+ # write or checkout mid-hook. Not a root anybody can prove; keep
236
+ # walking.
237
+ false
238
+ end
239
+ end
240
+ end
241
+ end