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,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module OKF
|
|
6
|
+
module Pro
|
|
7
|
+
# What the verbs did not cover — recorded, never enforced.
|
|
8
|
+
#
|
|
9
|
+
# Every verb in this gem is a gate or a report. This one is neither: it
|
|
10
|
+
# writes down the moments an agent did by hand something a command could
|
|
11
|
+
# have done, so the question "which verb is missing?" is answered by
|
|
12
|
+
# evidence instead of by the maintainer's imagination. Two points feed it,
|
|
13
|
+
# and both are code paths that already run — no new hook event, because
|
|
14
|
+
# `settings.json` is SEEDED and a new registration would never reach an
|
|
15
|
+
# adopter through `upgrade`.
|
|
16
|
+
#
|
|
17
|
+
# It records INTENT, not outcome. `shell-guard` fires at PreToolUse, before
|
|
18
|
+
# the owner may deny, so a recorded line means "this was attempted by
|
|
19
|
+
# hand", which is exactly the question being asked.
|
|
20
|
+
#
|
|
21
|
+
# THE THIRD CLAUSE, APPLIED TO TELEMETRY. This is not a check: it neither
|
|
22
|
+
# refuses nor blocks, and a recorder that crashed a gate would be a
|
|
23
|
+
# measurement worth less than the thing it measures. But it does not lie
|
|
24
|
+
# about having counted. A write that fails leaves a marker beside the log,
|
|
25
|
+
# and an unwritable scratch directory is the same state — both make
|
|
26
|
+
# `report` answer `available == false`, and the banner says the data is
|
|
27
|
+
# unavailable rather than printing a zero nobody can distinguish from a
|
|
28
|
+
# session that used the verbs.
|
|
29
|
+
module Friction
|
|
30
|
+
# The scratch directory the seeded `.gitignore` already ignores, at the
|
|
31
|
+
# REPOSITORY root rather than inside the bundle: this is telemetry about
|
|
32
|
+
# the bundle, not knowledge in it, and `okf validate` walks every `.md`
|
|
33
|
+
# under the root.
|
|
34
|
+
SCRATCH = ".tmp"
|
|
35
|
+
LOG = "okf-pro-friction.log"
|
|
36
|
+
MARKER = "okf-pro-friction.unavailable"
|
|
37
|
+
|
|
38
|
+
# What `report` hands back. `available` is the honest half: false means
|
|
39
|
+
# the count is not zero, it is unknown.
|
|
40
|
+
# `events`, not `entries`: a Struct member of that name overrides
|
|
41
|
+
# `Struct#entries` — Enumerable's alias for `to_a` — and a reader that
|
|
42
|
+
# got the Struct's own fields back instead of the recorded lines would
|
|
43
|
+
# be a silently wrong count, which is the one thing this must not be.
|
|
44
|
+
Report = Struct.new(:events, :unreadable, :available)
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# The repository the bundle sits in. `.okf/board.md` is a bundle under a
|
|
49
|
+
# repository; a flat root IS the repository. Nothing else distinguishes
|
|
50
|
+
# them, and the scratch directory belongs to the outer one either way.
|
|
51
|
+
def scratch_root(root)
|
|
52
|
+
base = File.expand_path(root.to_s)
|
|
53
|
+
File.basename(base) == BundleRoot::DIR ? File.dirname(base) : base
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def log_path(root)
|
|
57
|
+
File.join(scratch_root(root), SCRATCH, LOG)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def marker_path(root)
|
|
61
|
+
File.join(scratch_root(root), SCRATCH, MARKER)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One line appended, or a marker set. Never raises: the callers are a
|
|
65
|
+
# PreToolUse guard and a PostToolUse check, and an exception out of
|
|
66
|
+
# either is an edit sailing through while the gate lies on the floor.
|
|
67
|
+
def record(root, via, what, detail = nil, today: Date.today)
|
|
68
|
+
path = log_path(root)
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
70
|
+
entry = { "at" => today.to_s, "via" => via, "what" => what }
|
|
71
|
+
entry["detail"] = detail if detail
|
|
72
|
+
File.open(path, "a") { |file| file.puts JSON.generate(entry) }
|
|
73
|
+
true
|
|
74
|
+
rescue StandardError
|
|
75
|
+
mark_unavailable(root)
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def mark_unavailable(root)
|
|
80
|
+
path = marker_path(root)
|
|
81
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
82
|
+
File.open(path, "a") { |file| file.puts Date.today.to_s }
|
|
83
|
+
nil
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# Nothing left to try. `report` reaches the same verdict from the
|
|
86
|
+
# unwritable directory itself, which is why this may give up quietly.
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Entries in file order, plus the two things a reader must not be told
|
|
91
|
+
# by omission: how many lines would not parse, and whether the recorder
|
|
92
|
+
# was able to run at all.
|
|
93
|
+
def report(root)
|
|
94
|
+
events = []
|
|
95
|
+
unreadable = 0
|
|
96
|
+
path = log_path(root)
|
|
97
|
+
if File.exist?(path)
|
|
98
|
+
Pro.read_text(path).each_line do |line|
|
|
99
|
+
next if line.strip.empty?
|
|
100
|
+
|
|
101
|
+
parsed = parse(line)
|
|
102
|
+
parsed ? events << parsed : unreadable += 1
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
Report.new(events, unreadable, available?(root))
|
|
106
|
+
rescue StandardError
|
|
107
|
+
Report.new([], 0, false)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse(line)
|
|
111
|
+
value = JSON.parse(line)
|
|
112
|
+
value.is_a?(Hash) ? value : nil
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Writable, and never marked. An unwritable scratch directory has
|
|
118
|
+
# recorded nothing and cannot say so in a marker either, so it is asked
|
|
119
|
+
# directly rather than inferred from an empty log.
|
|
120
|
+
def available?(root)
|
|
121
|
+
return false if File.exist?(marker_path(root))
|
|
122
|
+
|
|
123
|
+
dir = File.join(scratch_root(root), SCRATCH)
|
|
124
|
+
return File.writable?(dir) if File.directory?(dir)
|
|
125
|
+
# Something is there and it is not a directory, so `mkdir_p` will raise
|
|
126
|
+
# every time and the marker cannot be written either — which is the
|
|
127
|
+
# case that proves the marker alone is not enough to answer this.
|
|
128
|
+
return false if File.exist?(dir)
|
|
129
|
+
|
|
130
|
+
File.writable?(scratch_root(root))
|
|
131
|
+
rescue StandardError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The good path must not count as friction, or the recorder reports the
|
|
136
|
+
# verbs being used as evidence that they are not.
|
|
137
|
+
#
|
|
138
|
+
# Anchored to the START of the segment, because the question is "is this
|
|
139
|
+
# an invocation of the gem?" and not "is the gem mentioned?". Matched
|
|
140
|
+
# anywhere, a board line's own TEXT excused the hand-write that appended
|
|
141
|
+
# it — `echo "- see okf pro docs" >> .okf/board.md` was never recorded,
|
|
142
|
+
# and board lines routinely name these verbs. `ShellGuard.own_write?`
|
|
143
|
+
# does the splitting; this is asked of one segment.
|
|
144
|
+
#
|
|
145
|
+
# An assignment prefix is part of the invocation and not a command of its
|
|
146
|
+
# own, so it is skipped rather than breaking the anchor: `$OKF_HOME` is a
|
|
147
|
+
# variable this ecosystem actually uses, and `OKF_HOME=/tmp okf pro
|
|
148
|
+
# snapshot >> .okf/log.md` is the prescribed move with a prefix on it.
|
|
149
|
+
#
|
|
150
|
+
# So is a wrapper, and the list is short and named rather than open:
|
|
151
|
+
# `bundle exec okf pro snapshot >> .okf/log.md` is *this repo's own*
|
|
152
|
+
# invocation of the prescribed move, and an anchor that missed it counted
|
|
153
|
+
# a contributor's first command as friction. An open rule would be a way
|
|
154
|
+
# back to matching a mention.
|
|
155
|
+
WRAPPERS = /(?:bundle\s+exec|env|time|nice|nohup|sudo|command)\s+/.freeze
|
|
156
|
+
OWN_COMMAND = /\A\s*(?:[A-Za-z_]\w*=\S*\s+)*(?:#{WRAPPERS})*okf\s+pro\b/.freeze
|
|
157
|
+
|
|
158
|
+
def own_command?(command)
|
|
159
|
+
command.to_s.match?(OWN_COMMAND)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The board, and ONLY the board.
|
|
163
|
+
#
|
|
164
|
+
# The obvious wider list — board, log, journal day — was wrong in the
|
|
165
|
+
# direction that destroys the measurement: it counted the PRESCRIBED path
|
|
166
|
+
# as friction. `snapshot` deliberately has no `--write`, so appending the
|
|
167
|
+
# Snapshot line to `log.md` by hand is exactly what this gem tells you to
|
|
168
|
+
# do; `journal open` says in as many words that the day's content is
|
|
169
|
+
# yours to write. Recording either inflates the banner's request and
|
|
170
|
+
# points the maintainer at verbs that already exist and already declined
|
|
171
|
+
# to do that job — which is the same mistake `own_command?` exists to
|
|
172
|
+
# prevent, made one layer up.
|
|
173
|
+
#
|
|
174
|
+
# An Edit to a concept body is judgment and always will be. What is left
|
|
175
|
+
# is `board.md`: a shape with exactly one correct form, edited by hand
|
|
176
|
+
# while three verbs cover it.
|
|
177
|
+
# The path is bundle-relative, so this is an equality rather than a
|
|
178
|
+
# basename test: `projects/x/board.md` is somebody's notes, not the one
|
|
179
|
+
# page Rule 3 counts, and the verbs do not touch it.
|
|
180
|
+
#
|
|
181
|
+
# One covered path also means the class a row records as IS the path, so
|
|
182
|
+
# the call site passes it straight through. The `classify` that used to
|
|
183
|
+
# fold three paths into three classes had two answers nothing could reach
|
|
184
|
+
# from this door, and a unit test calling it directly kept them green.
|
|
185
|
+
def covered_path?(rel)
|
|
186
|
+
rel.to_s == "board.md"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The two answers `covered_by` can give, as constants — so a reader may be
|
|
190
|
+
# COMPARED against one instead of pattern-matched. `verb_covered?` below
|
|
191
|
+
# asks a narrower question than `covered_by` answers, and a prefix test on
|
|
192
|
+
# the prose would have coupled it to wording nobody would think to
|
|
193
|
+
# preserve.
|
|
194
|
+
SHELL_ANSWER = "Edit or Write — the trust guards read a tool event, and a shell redirect is none"
|
|
195
|
+
BOARD_ANSWER = "okf pro capture / promote / demote"
|
|
196
|
+
|
|
197
|
+
# What a recorded row maps to, where something already covers it — so the
|
|
198
|
+
# report says "this is a verb now" rather than making the reader guess,
|
|
199
|
+
# and phase 3's question keeps being answered after phase 3.
|
|
200
|
+
#
|
|
201
|
+
# Keyed on `via` as well as `what`, because the two doors mean different
|
|
202
|
+
# things about the same file. An Edit to the board is a verb's job. A
|
|
203
|
+
# SHELL write is a bypass whatever it touched — the trust guards read a
|
|
204
|
+
# tool event and a redirect produces none — so what covers it is Edit or
|
|
205
|
+
# Write, not an `okf pro` verb.
|
|
206
|
+
def covered_by(via, what)
|
|
207
|
+
return SHELL_ANSWER if via == "shell"
|
|
208
|
+
return BOARD_ANSWER if what == "board.md"
|
|
209
|
+
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether an `okf pro` VERB covers it — which is not the same question as
|
|
214
|
+
# whether anything does. A shell redirect at the board is covered, by Edit
|
|
215
|
+
# or Write, and there is no verb to ask for; a banner counting it told the
|
|
216
|
+
# adopter a verb could have done something no verb does.
|
|
217
|
+
def verb_covered?(via, what)
|
|
218
|
+
answer = covered_by(via, what)
|
|
219
|
+
!answer.nil? && answer != SHELL_ANSWER
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Everything the log holds, cleared — the log itself and the marker.
|
|
223
|
+
#
|
|
224
|
+
# The marker is deliberately sticky: one failed write means the count is
|
|
225
|
+
# short by an unknown amount forever after, and a recorder that quietly
|
|
226
|
+
# forgave itself would be back to reporting a zero it did not count. But
|
|
227
|
+
# sticky with no way out is a report that nags permanently about a full
|
|
228
|
+
# disk from three weeks ago, so the way out is explicit, named in the
|
|
229
|
+
# message, and belongs to the reader.
|
|
230
|
+
def clear(root)
|
|
231
|
+
[ log_path(root), marker_path(root) ].count do |path|
|
|
232
|
+
next false unless File.exist?(path)
|
|
233
|
+
|
|
234
|
+
File.unlink(path)
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
rescue StandardError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The covered classes, read out of a shell COMMAND instead of a path. A
|
|
242
|
+
# command has no `file_path` — that is the whole reason `shell-guard`
|
|
243
|
+
# exists — so this reads the names it mentions, and answers "unclassified"
|
|
244
|
+
# when it cannot tell. That is not a failure: the report's question is which
|
|
245
|
+
# verb is missing, and a write it cannot attribute is exactly the row that
|
|
246
|
+
# says "we do not know yet".
|
|
247
|
+
#
|
|
248
|
+
# Wider than `covered_path?`, deliberately, and the two doors are asking
|
|
249
|
+
# different questions: the edit door asks "is this a shape a verb covers?",
|
|
250
|
+
# and a redirect is a bypass whatever it touched. `journal` collapses a year
|
|
251
|
+
# of days into one row rather than three hundred.
|
|
252
|
+
def classify_command(command)
|
|
253
|
+
text = command.to_s
|
|
254
|
+
return "board.md" if text.include?("board.md")
|
|
255
|
+
return "log.md" if text.include?("log.md")
|
|
256
|
+
return "journal" if text.match?(%r{journal/\d{4}-\d{2}-\d{2}\.md})
|
|
257
|
+
|
|
258
|
+
"unclassified"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The trust rules. Both are PreToolUse, so they deny the write rather than
|
|
6
|
+
# complain after it, and both scope through Target first: `verified:` and
|
|
7
|
+
# the journal calendar are bundle vocabulary, and a guard that fired on
|
|
8
|
+
# them anywhere — the README quoting the frontmatter format, a scratch
|
|
9
|
+
# file under .tmp/ — was a false block that trains people to switch the
|
|
10
|
+
# guard off.
|
|
11
|
+
module Guards
|
|
12
|
+
# The block-style spelling, and the fast path. It is NOT the whole
|
|
13
|
+
# rule: YAML frontmatter may be a flow mapping — `{type: Briefing,
|
|
14
|
+
# verified: 2026-08-01}` — which the okf reader parses as a real
|
|
15
|
+
# attestation and this pattern never sees. A gate that reads a
|
|
16
|
+
# narrower grammar than the parser it defends is a gate with a door
|
|
17
|
+
# cut into it, so the flow spelling gets its own pattern below.
|
|
18
|
+
# Patterns rather than a YAML parse, because the added text of an
|
|
19
|
+
# Edit is a FRAGMENT — it need not be valid YAML, or even contain
|
|
20
|
+
# the fences — and a parser that failed on a fragment would decline
|
|
21
|
+
# to see the very attestation it was called to catch.
|
|
22
|
+
ATTESTATION = /^[[:blank:]]*verified[[:blank:]]*:/.freeze
|
|
23
|
+
|
|
24
|
+
# `verified` as a key ANYWHERE in a flow mapping — after `{`, after a
|
|
25
|
+
# comma, at any nesting. Matched against the added text only when it
|
|
26
|
+
# actually contains a flow mapping, so prose that happens to say
|
|
27
|
+
# "verified:" in a sentence does not trip it.
|
|
28
|
+
FLOW_ATTESTATION = /[{,][[:blank:]]*['"]?verified['"]?[[:blank:]]*:/.freeze
|
|
29
|
+
|
|
30
|
+
JOURNAL_ENTRY = %r{\Ajournal/(\d{4}-\d{2}-\d{2})\.md\z}.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Every spelling of the attestation the okf reader would accept. The
|
|
35
|
+
# test is deliberately generous: this gate ASKS, so a false positive
|
|
36
|
+
# costs one prompt and a miss costs a forged signature.
|
|
37
|
+
def attests?(text)
|
|
38
|
+
text = text.to_s
|
|
39
|
+
ATTESTATION.match?(text) || FLOW_ATTESTATION.match?(text)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `verified:` is the owner's signature, and an agent that can type it
|
|
43
|
+
# unchecked can forge it. Everything downstream — the To-read line, the
|
|
44
|
+
# briefing's standing, the audit — rests on that block meaning a person
|
|
45
|
+
# actually read the source. This is the most load-bearing gate in the repo.
|
|
46
|
+
#
|
|
47
|
+
# It asks rather than denies, because the invariant was never who holds
|
|
48
|
+
# the pen — it is who decided. The owner reviews with the agent and the
|
|
49
|
+
# agent promotes the change; this gate turns that write into an explicit
|
|
50
|
+
# owner approval, and the approval IS the attestation. Where nobody can
|
|
51
|
+
# approve — an unattended session — "ask" has no approver and the write
|
|
52
|
+
# is refused, which is the fail-closed floor the old outright denial had.
|
|
53
|
+
def guard_verified(event)
|
|
54
|
+
return [] if Target.for(event).nil?
|
|
55
|
+
return [] unless attests?(event.added_text)
|
|
56
|
+
|
|
57
|
+
{ "ask" => "This edit writes 'verified:' — owner attestation. The agent is the scribe; " \
|
|
58
|
+
"your approval is the attestation. Approve ONLY if you have actually reviewed " \
|
|
59
|
+
"this content yourself. If you have not, deny — unverified is the truth." }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A past day is a record. Editing it does not correct history, it destroys
|
|
63
|
+
# the one artefact that says what was known at the time — that stays a
|
|
64
|
+
# flat refusal. Creating a missing past day is different, and it is the
|
|
65
|
+
# owner's call, not the agent's: Rule 2's fallback is reconstructing
|
|
66
|
+
# yesterday's missing entry, declared as reconstructed — but "the file
|
|
67
|
+
# does not exist" alone would let an agent fabricate any date in the
|
|
68
|
+
# past as quietly as filing a note, and the same guard would then defend
|
|
69
|
+
# the forgery as history. So creation asks, exactly like `verified:`:
|
|
70
|
+
# interactive, the owner approves the reconstruction; unattended, no
|
|
71
|
+
# approver, fail closed.
|
|
72
|
+
def journal_guard(event, today: Date.today)
|
|
73
|
+
target = Target.for(event)
|
|
74
|
+
return [] if target.nil?
|
|
75
|
+
|
|
76
|
+
match = target.rel.match(JOURNAL_ENTRY)
|
|
77
|
+
return [] unless match
|
|
78
|
+
return [] unless match[1] < today.to_s
|
|
79
|
+
|
|
80
|
+
unless target.exist?(target.rel)
|
|
81
|
+
return { "ask" => "This creates journal/#{match[1]}.md — a PAST day that has no entry. " \
|
|
82
|
+
"Approve ONLY if this is a declared reconstruction of a day you " \
|
|
83
|
+
"actually worked (Rule 2's fallback). A fabricated record is worse " \
|
|
84
|
+
"than a gap: this guard will defend whatever you approve as history." }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
[ "BLOCKED — journal/#{match[1]}.md is a past day. Records are append-only; " \
|
|
88
|
+
"corrections go in today's entry, pointing back." ]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
module Log
|
|
6
|
+
# log.md's one text transform — a dated line, appended under its day.
|
|
7
|
+
#
|
|
8
|
+
# The counterpart of Board::Edit, and separate from it for the reason
|
|
9
|
+
# `Log` is separate from `Board`: the log's newest-first convention is a
|
|
10
|
+
# rule of this file alone, and it is the rule every earlier defect here
|
|
11
|
+
# turned on — the audit interrogating the oldest day, the banner printing
|
|
12
|
+
# day one's counters forever. A new day's heading therefore goes at the
|
|
13
|
+
# TOP of the day sequence, not at the end of the file.
|
|
14
|
+
#
|
|
15
|
+
# Returns `[text, added]`, where `added` is the exact list of lines
|
|
16
|
+
# spliced in — the caller's claim to `Conserve`, blank lines included,
|
|
17
|
+
# because a blank line is a line and a guard that ignored them could be
|
|
18
|
+
# walked past with one.
|
|
19
|
+
module Edit
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def add_entry(text, day, line)
|
|
23
|
+
lines = Pro.newline_terminated(text).lines
|
|
24
|
+
heading = "## #{day}"
|
|
25
|
+
at = lines.index { |l| l.start_with?("## ") && l.chomp.rstrip == heading }
|
|
26
|
+
at ? into_existing_day(lines, at, line) : as_a_new_day(lines, heading, line)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# After the day's last non-blank line, so the entry joins the day
|
|
30
|
+
# rather than floating below it — and before whatever blank line
|
|
31
|
+
# separates this day from the next.
|
|
32
|
+
def into_existing_day(lines, at, line)
|
|
33
|
+
index = at + 1
|
|
34
|
+
last = at + 1
|
|
35
|
+
while index < lines.size
|
|
36
|
+
break if lines[index].start_with?("## ")
|
|
37
|
+
|
|
38
|
+
last = index + 1 unless lines[index].strip.empty?
|
|
39
|
+
index += 1
|
|
40
|
+
end
|
|
41
|
+
added = [ "#{line}\n" ]
|
|
42
|
+
lines.insert(last, *added)
|
|
43
|
+
[ lines.join, added ]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Before the first existing day, which in a newest-first log is where
|
|
47
|
+
# a newer day belongs. With no day at all yet, at the end — a brand-new
|
|
48
|
+
# bundle ships an undated log whose whole body is its own preamble.
|
|
49
|
+
def as_a_new_day(lines, heading, line)
|
|
50
|
+
first = lines.index { |l| l.start_with?("## ") }
|
|
51
|
+
if first
|
|
52
|
+
added = [ "#{heading}\n", "\n", "#{line}\n", "\n" ]
|
|
53
|
+
lines.insert(first, *added)
|
|
54
|
+
else
|
|
55
|
+
# The separator counts. It is a line the file did not have, and a
|
|
56
|
+
# guard that let an unstated blank through is a guard an edit can
|
|
57
|
+
# be walked past one blank at a time.
|
|
58
|
+
added = []
|
|
59
|
+
added << "\n" unless lines.empty? || lines.last.strip.empty?
|
|
60
|
+
added.push("#{heading}\n", "\n", "#{line}\n")
|
|
61
|
+
lines.concat(added)
|
|
62
|
+
end
|
|
63
|
+
[ lines.join, added ]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/okf/pro/log.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# log.md as data. Pure, for the same reason Board is.
|
|
6
|
+
module Log
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# The shape of the line Rule 2 appends: a list bullet, then the word,
|
|
10
|
+
# then its colon — bolding optional, spacing free. Substring matching
|
|
11
|
+
# ("any line containing Snapshot") was a hole in both directions: a
|
|
12
|
+
# prose bullet like "- Rewrote the Snapshot checker" under today's
|
|
13
|
+
# heading posed as the day's snapshot, so the stop gate skipped its
|
|
14
|
+
# accurate missing-line refusal and reported twelve bogus
|
|
15
|
+
# disagreements instead — and the audit passed CI on a mention with
|
|
16
|
+
# no real snapshot anywhere.
|
|
17
|
+
SNAPSHOT_LINE = /\A[[:blank:]]*[*+-][[:blank:]]+\*{0,2}Snapshot\*{0,2}[[:blank:]]*:/.freeze
|
|
18
|
+
|
|
19
|
+
# Under *that* day's heading and no further. The shell version set its flag
|
|
20
|
+
# at the day heading and never cleared it, so yesterday's snapshot
|
|
21
|
+
# satisfied today — which is exactly the check being asked for.
|
|
22
|
+
def snapshot_under?(text, day)
|
|
23
|
+
!snapshot_line(text, day).nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The one walk over the log's day blocks — every public reader below
|
|
27
|
+
# consumes it, so the heading rule, the calendar rule, and the
|
|
28
|
+
# last-line-wins rule cannot drift apart again (they did, twice: a
|
|
29
|
+
# hand-rolled heading compare beside DAY_HEADING, then a calendar
|
|
30
|
+
# check present in one loop and absent from the other). Yields
|
|
31
|
+
# [day, line] per block in file order: day is the heading's captured,
|
|
32
|
+
# calendar-valid date; line is the LAST snapshot line within that
|
|
33
|
+
# block — a corrected line is appended rather than history rewritten,
|
|
34
|
+
# and the last word is the current one — or nil when the block has
|
|
35
|
+
# none.
|
|
36
|
+
def each_day_block(text)
|
|
37
|
+
day = nil
|
|
38
|
+
line = nil
|
|
39
|
+
text.to_s.each_line do |l|
|
|
40
|
+
if l.start_with?("## ")
|
|
41
|
+
yield [ day, line ] if day
|
|
42
|
+
day = accepted_day(l)
|
|
43
|
+
line = nil
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
line = l if day && l.match?(SNAPSHOT_LINE)
|
|
47
|
+
end
|
|
48
|
+
yield [ day, line ] if day
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The heading acceptance rule, ONCE: the captured date when the line
|
|
52
|
+
# is a calendar-valid day heading, else nil. Both walks — the block
|
|
53
|
+
# iterator above and the audit's partition below — call this, so a
|
|
54
|
+
# tolerance change cannot land in one and leave the other silently
|
|
55
|
+
# disagreeing about which days exist.
|
|
56
|
+
def accepted_day(line)
|
|
57
|
+
match = line.match(DAY_HEADING)
|
|
58
|
+
match && Board.parse_date(match[1]) ? match[1] : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The day's Snapshot line. The same date can head two blocks — two
|
|
62
|
+
# sessions in one day, or a merge — and every block counts, but the
|
|
63
|
+
# file runs newest-first, so the FIRST block holding a snapshot wins:
|
|
64
|
+
# taking the last block in file order handed the stale bottom copy to
|
|
65
|
+
# the very gate that verifies counters against the board.
|
|
66
|
+
def snapshot_line(text, day)
|
|
67
|
+
each_day_block(text) do |d, line|
|
|
68
|
+
return line if line && d == day.to_s
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The full shape, anchored at both ends: unanchored, `## 2026-08-123`
|
|
74
|
+
# captured a valid-looking date out of a typo and adopted the block,
|
|
75
|
+
# while `## 2026-8-12` (missing zero-pad) matched nothing and fell
|
|
76
|
+
# out of BOTH buckets — neither checked nor reported.
|
|
77
|
+
DAY_HEADING = /^## (\d{4}-\d{2}-\d{2})[[:blank:]\r]*$/.freeze
|
|
78
|
+
|
|
79
|
+
# What counts as an ATTEMPT at a day heading: `## ` then a digit.
|
|
80
|
+
# "## Notes" is a legal non-day heading; "## 2026-8-12" is a day
|
|
81
|
+
# heading gone wrong, and the difference is what keeps malformed_days
|
|
82
|
+
# from flagging prose while still catching every typo class.
|
|
83
|
+
DAYISH_HEADING = /^## [[:blank:]]*\d/.freeze
|
|
84
|
+
|
|
85
|
+
# One scan, one partition: every heading that attempts a date lands in
|
|
86
|
+
# exactly one bucket, so a rule change cannot open a gap where a
|
|
87
|
+
# heading is neither checked nor reported. days() is the calendar's
|
|
88
|
+
# accepted list — a typo'd `## 2026-08-32` string-sorts above every
|
|
89
|
+
# real date, and unvalidated it became the "newest day" the audit
|
|
90
|
+
# interrogated while the genuine newest day carried none.
|
|
91
|
+
def day_partition(text)
|
|
92
|
+
good = []
|
|
93
|
+
bad = []
|
|
94
|
+
text.to_s.each_line do |line|
|
|
95
|
+
next unless line.match?(DAYISH_HEADING)
|
|
96
|
+
|
|
97
|
+
day = accepted_day(line)
|
|
98
|
+
if day
|
|
99
|
+
good << day
|
|
100
|
+
else
|
|
101
|
+
bad << line.chomp.rstrip.sub(/\A## /, "")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
[ good, bad ]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def days(text)
|
|
108
|
+
day_partition(text)[0]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The headings days() refused, for the audit to say out loud — a
|
|
112
|
+
# heading no check will ever look under must not rot in silence.
|
|
113
|
+
def malformed_days(text)
|
|
114
|
+
day_partition(text)[1]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The maximum date, not the first heading. The file's own convention is
|
|
118
|
+
# newest-first, but prose is the only thing that ever said so — an entry
|
|
119
|
+
# appended at the bottom made the audit interrogate the *oldest* day,
|
|
120
|
+
# which had its snapshot, and CI passed while the real newest day had
|
|
121
|
+
# none. What CI asks about is the newest day rather than today, because
|
|
122
|
+
# a push can land on a day nobody worked and a gate that fails on the
|
|
123
|
+
# calendar is a gate people learn to ignore.
|
|
124
|
+
def newest_day(text)
|
|
125
|
+
days(text).max
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The most recent snapshot in the file, by date, WITH the day it was found
|
|
129
|
+
# under. The session banner and `okf pro state` both label the parsed
|
|
130
|
+
# counters "as of" that day rather than as live, and a label re-derived
|
|
131
|
+
# by a second scan would be a second chance to disagree with the line it
|
|
132
|
+
# labels. (A line-only sibling stood here until `state` became the one
|
|
133
|
+
# source both surfaces read; it was a two-line delegation to this, and
|
|
134
|
+
# only a unit test still called it.)
|
|
135
|
+
#
|
|
136
|
+
# By DATE, not by position. Grepping the whole file and taking the last
|
|
137
|
+
# hit read the file's *bottom*, which in a newest-first log is the oldest
|
|
138
|
+
# entry: from day two onward the banner reported the first-ever counters,
|
|
139
|
+
# inverting the delta it exists to show.
|
|
140
|
+
#
|
|
141
|
+
# One pass, O(filesize), because this runs at every session start on a
|
|
142
|
+
# file that only grows: the composed version cost a full rescan per
|
|
143
|
+
# candidate day, which was seconds on a years-old log exactly when
|
|
144
|
+
# discipline had lapsed and the banner mattered most. Two scalars, not
|
|
145
|
+
# per-day hashes — the strict greater-than keeps the first block of a
|
|
146
|
+
# repeated day, the newest-first winner, for free, and there is no second
|
|
147
|
+
# copy of the walk left to drift.
|
|
148
|
+
def latest_snapshot_entry(text)
|
|
149
|
+
best_day = nil
|
|
150
|
+
best_line = nil
|
|
151
|
+
each_day_block(text) do |day, line|
|
|
152
|
+
next unless line
|
|
153
|
+
next unless best_day.nil? || day > best_day
|
|
154
|
+
|
|
155
|
+
best_day = day
|
|
156
|
+
best_line = line
|
|
157
|
+
end
|
|
158
|
+
[ best_day, best_line ]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|